mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
dfbaeafbd8
commit
13efd411b6
112 files changed
+4419
-4856
No files matched your search
@@ -117,20 +117,6 @@ LL2_API_TOKEN=
|
||||
# Optional: OpenSky credentials JSON file path (alternative to above)
|
||||
# OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json
|
||||
|
||||
# Optional: interface language pair (i18n). GEV_DEFAULT_LOCALE is the default
|
||||
# UI locale; GEV_SECONDARY_LOCALE is the second locale offered in the dock
|
||||
# language switch. English is ALWAYS shipped as the fallback catalog and is
|
||||
# resolvable no matter the pair. Only 'en' ships in this PR: both values are
|
||||
# validated against the shipped catalogs, so a secondary without a shipped
|
||||
# catalog degenerates the switch to a single English button (and an invalid,
|
||||
# unshipped, or degenerate pair falls back to that en-only shape with a
|
||||
# dev-server-only console.warn). Later locale PRs re-open the pair — see
|
||||
# docs/TRANSLATORS.md. CLIENT-EXPOSED (non-secret): both values reach the
|
||||
# browser bundle via import.meta.env defines — do not put anything sensitive
|
||||
# here.
|
||||
# GEV_DEFAULT_LOCALE=en
|
||||
# GEV_SECONDARY_LOCALE=es
|
||||
|
||||
# Optional: dev server config
|
||||
PORT=4173
|
||||
# Network binding. Default is localhost — the server is reachable ONLY from
|
||||
|
||||
+10
-23
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
- Interface localization, starting with Russian. UI text is marked with
|
||||
`t()`, `tn()` (plurals) and `msg()` in code and `data-i18n*` in markup, keyed
|
||||
by the English text itself; each language is one flat JSON catalog
|
||||
(`src/i18n/locales/ru.json`). The language comes from `?lang=`, the EN/RU
|
||||
switch in the Visual Presets dock, then the browser language; English stays
|
||||
the default and the fallback. `npm run i18n`, also run by `npm test`, fails on
|
||||
missing, unused or broken translations. Built on the i18n foundation by
|
||||
@meirm (#601), with the Russian catalog started from #312.
|
||||
|
||||
- Treat explicitly current, complete empty vessel coverage as a successful update,
|
||||
clearing obsolete contacts while retaining stale or incomplete snapshots.
|
||||
|
||||
@@ -318,29 +327,6 @@
|
||||
keeps frame time with ALPR on within a few percent of ALPR off, seats nearby
|
||||
badges on the rendered surface and says when no loaded camera is on screen.
|
||||
|
||||
## Internationalization foundation
|
||||
|
||||
- Add the locale core (`src/i18n/`): configurable locale pair
|
||||
(`GEV_DEFAULT_LOCALE` / `GEV_SECONDARY_LOCALE`, validated against the
|
||||
shipped catalogs), guarded preference storage, a one-shot `?lang=`
|
||||
override, and `<html lang>`/`<html dir>` metadata. English is the only
|
||||
shipped locale — the built-in pair degenerates to en-only and the dock
|
||||
language switch renders a single EN button — and English behavior is
|
||||
byte-identical to before.
|
||||
- Extract all application-owned presentation text into four English
|
||||
catalogs (`shell`/`cockpit`/`layers`/`setup`) consumed through `t()` call
|
||||
sites, `data-i18n*` template attributes, and the runtime locale selector;
|
||||
complete the duplicate `data-i18n` split on the first-run EXPLORE
|
||||
MANUALLY tile (the subcopy was wiped in every locale).
|
||||
- Enforce the parity gates every additional locale must pass (key /
|
||||
placeholder / plural-shape parity, markup coverage, repair-pass anchors).
|
||||
Additional locales ship as stacked follow-up PRs, one per locale, via the
|
||||
locale-addition recipe in `docs/TRANSLATORS.md` (Spanish is first).
|
||||
|
||||
- Let CLI tools, development launchers and the setup doctor use an explicit project directory while retaining their existing default paths.
|
||||
|
||||
- Split application scene, controls, catalog, tools and HTML into reusable components; configure application request services and sources without changing global fetch. Preserve standalone markup and voice behavior. Explicit annotation navigation may resolve a distant named target.
|
||||
|
||||
- CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived
|
||||
from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the
|
||||
flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD
|
||||
@@ -789,6 +775,7 @@ Analyst records for loaded satellites, datacenters and dams, with explicit bound
|
||||
- Separate navigation, share restoration, visual settings and panel state into focused UI owners with explicit dependencies and terminal cleanup.
|
||||
|
||||
- Separate layer lifecycle transactions from panel construction and render/detection reactions; retain existing transition and refresh behavior.
|
||||
|
||||
- Let CLI tools, development launchers and the setup doctor use an explicit project directory while retaining their existing default paths.
|
||||
|
||||
- Split application scene, controls, catalog, tools and HTML into reusable components; configure application request services and sources without changing global fetch. Preserve standalone markup and voice behavior. Explicit annotation navigation may resolve a distant named target.
|
||||
|
||||
@@ -52,8 +52,6 @@ export function createBrowserViteConfig({
|
||||
publicDir,
|
||||
googleApiKey,
|
||||
cesiumToken,
|
||||
defaultLocale = '',
|
||||
secondaryLocale = '',
|
||||
mapillaryToken,
|
||||
host = 'localhost',
|
||||
port = 4173,
|
||||
@@ -109,14 +107,6 @@ export function createBrowserViteConfig({
|
||||
define: {
|
||||
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
|
||||
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken),
|
||||
// Locale pair (i18n): the client's default + secondary UI locale. These
|
||||
// are NOT secrets and are read by src/i18n/locale.js, which validates
|
||||
// them against the shipped catalogs and falls back to the built-in
|
||||
// en-only pair (while English is the only shipped catalog) for
|
||||
// empty/invalid/unshipped values. Explicit define entries are required
|
||||
// because Vite only auto-exposes VITE_-prefixed vars on import.meta.env.
|
||||
'import.meta.env.GEV_DEFAULT_LOCALE': JSON.stringify(defaultLocale),
|
||||
'import.meta.env.GEV_SECONDARY_LOCALE': JSON.stringify(secondaryLocale),
|
||||
// Mapillary client tokens are public by design (see SECURITY.md).
|
||||
'import.meta.env.MAPILLARY_CLIENT_TOKEN': JSON.stringify(
|
||||
mapillaryToken ?? '',
|
||||
|
||||
+20
-116
@@ -3009,124 +3009,28 @@ test:track` 43 tracking invariants · headless QA harnesses under
|
||||
> attribution 18/18). New modules: `src/data/{motionModel,aircraftMeta,aircraftClass,aircraftIcons,issPass,routePlausible,dataCredits}.js`.
|
||||
> The live runtime now declares 30 voice tools; the 17→20 count above is retained only as milestone history.
|
||||
|
||||
### Internationalization / catalog locales (September 2026)
|
||||
### Interface localization (October 2026)
|
||||
|
||||
The application-owned UI is fully extracted into message catalogs; **en**
|
||||
is the only shipped locale in this foundation state — additional locales
|
||||
land as stacked follow-up PRs, one per locale (Spanish first), each
|
||||
following the locale-addition recipe in
|
||||
[`docs/TRANSLATORS.md`](./TRANSLATORS.md). English is the default, the
|
||||
source catalog, and the fallback. The subsystem lives in `src/i18n/`:
|
||||
Shipped locales: **en** (source and fallback) and **ru**. The code lives in
|
||||
`src/i18n/`; the contributor workflow is in [`docs/TRANSLATORS.md`](./TRANSLATORS.md).
|
||||
|
||||
- **File map.** `src/i18n/locale.js` owns locale resolution, guarded storage
|
||||
(`gev:locale:v1`), the one-shot `?lang=` override, and `<html lang>`/`<html
|
||||
dir>` metadata. `src/i18n/index.js` owns the catalog registry, `t()`
|
||||
(interpolation + `Intl.PluralRules` plural selection), `formatNumber` /
|
||||
`formatDate`, `applyDocumentTranslations()`, and
|
||||
`persistLocaleAndReload()`. The shipped catalog is four flat message maps
|
||||
— `src/i18n/locales/en/{shell,cockpit,layers,setup}.js`, 912 keys (shell
|
||||
34, cockpit 369, layers 388, setup 121). Plural entries are the en
|
||||
`{ one, other }` shape; a future locale may add only cardinal categories
|
||||
`Intl.PluralRules` reports valid for it, and an entry lacking the selected
|
||||
category degrades to `other` at runtime. Locale-code normalization
|
||||
(`es-MX`/`es_419` → `es`, plus fr/ru/uk and variants) ships ahead of the
|
||||
locales on purpose: knowing a code is hygiene, shipping is catalog-driven
|
||||
through `CATALOG_LOCALES`.
|
||||
- **Selector.** The command dock carries a compact locale switch: a static
|
||||
`.dock-locale-switch` group container in `index.html` whose buttons
|
||||
(`.dock-locale-btn`) are rendered at runtime by `src/ui/applicationShell.js`
|
||||
`_initLocaleSelector()` — one per locale in the configured pair; while
|
||||
only English ships that is a single EN button. A click persists the
|
||||
choice and reloads the page with the hash preserved, so no live re-apply
|
||||
of already-rendered dynamic panels is needed.
|
||||
- **Locale pair config.** `GEV_DEFAULT_LOCALE` and `GEV_SECONDARY_LOCALE` in
|
||||
`.env`, injected as `import.meta.env` defines by `vite.config.js`; both
|
||||
values are validated against the shipped catalogs (`CATALOG_LOCALES`),
|
||||
and the offered set is `dedup([default, secondary, 'en'])`. While English
|
||||
is the only shipped catalog the built-in pair is en-only, so every
|
||||
configuration degenerates to a single offered locale; an invalid,
|
||||
unshipped, or degenerate configured pair falls back to that built-in
|
||||
en-only shape (dev-only warn). The first secondary-locale PR restores the
|
||||
built-in en+es pair.
|
||||
- **Resolution order.** `?lang=<locale>` (search string only; never
|
||||
persisted, never written into share links; stripped by
|
||||
`persistLocaleAndReload`; accepted only for offered locales) → stored
|
||||
`gev:locale:v1` (re-checked against the pair) →
|
||||
`navigator.languages` (regional variants normalize to the primary tag:
|
||||
`es-MX`/`es_419` → `es`) → the CONFIGURED default locale. Unsupported
|
||||
values defer to the next
|
||||
step rather than forcing English.
|
||||
- **Gates** (all under `node --test src/i18n/`, 40 tests):
|
||||
`catalog.test.mjs` enforces per-locale key, placeholder-name, and
|
||||
plural-shape parity with the strict exact-parity flip ON
|
||||
(`REQUIRE_FULL_PARITY`; `GEV_I18N_REQUIRE_FULL_LOCALE_PARITY=0` opts out
|
||||
for staged work; the loops are vacuous while only en ships — each locale
|
||||
PR meets them from day one);
|
||||
`markupCoverage.test.mjs` requires every `data-i18n*` attribute in
|
||||
`index.html` to resolve in every shipped catalog and rejects unknown attribute
|
||||
spellings; `i18n.test.mjs` pins precedence, guards, fallback,
|
||||
interpolation, the plural-selection contract (synthetic missing-category
|
||||
degradation; real-catalog plural pins arrive with each locale PR), and
|
||||
DOM application; `repairPass.test.mjs` anchors en byte-identity for
|
||||
extracted literals and rejects a locale-scoped CCTV wrap rule shipping
|
||||
ahead of its locale (per-locale value anchors arrive with each locale
|
||||
PR).
|
||||
- **Accepted deferrals (do not "fix" silently):**
|
||||
- The military-awareness subject header literal `FLIGHT / VESSEL WINDOW`
|
||||
(`src/data/militaryAwareness.js`) stays English; the literal is
|
||||
test-pinned in `src/data/militaryAwareness.test.mjs`.
|
||||
- The visible cockpit-brief tab tokens `SIG` / `NEWS` / `LOCAL`
|
||||
(`index.html`) stay untranslated to match the runtime keys; their
|
||||
`aria-label`s ARE localized (`cockpit.brief.tab*AriaLabel`).
|
||||
- `src/voice/gevActions.js` tool-result confirmation strings stay English
|
||||
by contract (voice tool schemas and spoken confirmations are
|
||||
keep-English; see the Keep-English boundary in `docs/TRANSLATORS.md`).
|
||||
- Number formatting is still the pre-i18n `toLocaleString` policy at:
|
||||
`src/ui.js:1442`, `src/data/satellites.js:829`,
|
||||
`src/data/flights.js:436` (`'en-US'` pinned), and
|
||||
`src/data/rocketLaunches.js:2281/2299/2364/2371/2374`
|
||||
(default-locale). A locale-aware number-format policy is deferred; the
|
||||
`formatNumber` helper exists in `src/i18n/index.js` when that lands.
|
||||
|
||||
### Overpass proxy mirror rotation (September 2026)
|
||||
|
||||
- `/api/overpass` fans out across four public mirrors. `overpassPayloadIsData()` governs cache reads, writes, and stale fallback: only a 2xx that is neither rate-limited nor a body-level runtime error qualifies. Previously stored refusals are ignored on both fresh and stale reads, so upgrading does not require manually clearing the disk cache.
|
||||
- HTTP refusals such as 406 now rotate alongside the existing network, rate-limit, and runtime-error cases. A refusal from one mirror no longer prevents reaching healthy alternatives or persists under the seven-day road/month-long boundary cache TTLs. Concurrent identical queries share one mirror sequence; if it fails, both the initiating and joined callers can use the same last-good data.
|
||||
- A refusal every mirror agrees on is still reported with the first mirror's status and body, so a genuinely malformed query says what upstream said — but only after every mirror has had the chance to answer it. `fetchOverpassPayload` takes injectable endpoints and fetch so the rotation is tested without a live mirror (`src/overpassProxy.test.mjs`).
|
||||
|
||||
### Share-link v2 layer state (August 2026)
|
||||
|
||||
- Generated share links use a deterministic v2 hash. Existing camera, visual,
|
||||
HUD, detection, post-processing, celestial, scope, and map-stack fields remain,
|
||||
with compact fields for enabled layers, allowlisted layer options, panel state,
|
||||
and the active preset's allowlisted shader controls. An absent layer field uses
|
||||
deterministic defaults; an explicit empty field means no enabled layers.
|
||||
- The registry seals only after all 16 production layers register, and every
|
||||
layer has an explicit serialization disposition. Unknown enabled-layer tokens
|
||||
reject the layer payload; unknown option tokens are ignored. Restoration
|
||||
settles independently per layer so one failed or unavailable source cannot
|
||||
block its siblings.
|
||||
- Stable visible options are limited to aircraft 3D mode, selected civilian and
|
||||
military flight IDs, Satellite catalog and selection, CCTV coverage/projection/
|
||||
auto-hop, and Radio filter/volume. Playback and tuning, live-data health,
|
||||
calibration, caches, lifecycle state, temporary Context ownership, and derived
|
||||
effects are deliberately excluded. Radio restore never selects or plays a
|
||||
station.
|
||||
- Normal loads restore the last successful explicit UI, voice, or tool choice
|
||||
from versioned local storage. Any valid camera share wins for the current load
|
||||
without overwriting recipient preferences. Restore ownership is split by
|
||||
visibility, option/selection, camera, visual, map, and individual panel lane:
|
||||
a newer explicit action supersedes only the field it owns. In particular,
|
||||
navigation cannot turn unrelated layers off, and an option change cannot
|
||||
|
||||
#### Keep-English adjudications (post-merge catch-ups)
|
||||
|
||||
- `src/ui/visualPresets.js` `'ELASTIC'` — detection-allocation STRATEGY IDENTIFIER (persisted
|
||||
under `gev:detection-allocation`, compared via `dataset.allocation ===`), not display copy.
|
||||
- `src/ui/layerPanel.js` guidance meta — the `{source} · {statusMessage}` composition uses a
|
||||
locale-neutral interpunct; the statusMessage itself IS localized via `installationFeedback`.
|
||||
- `src/ui/mapSourceControls.js` `'...'` switching label and the orbit `'↻'` glyph —
|
||||
punctuation/glyphs, identical across locales.
|
||||
- **Keys are the English text.** `t('Save keys')`, `tn(count, '{count} camera',
|
||||
'{count} cameras')`, and `msg('Live Flights')` for text stored in data and
|
||||
translated later. Markup opts in with `data-i18n` (element text) and
|
||||
`data-i18n-title` / `-aria-label` / `-placeholder` (that attribute's English
|
||||
value). English has no catalog; every other locale is
|
||||
`src/i18n/locales/<code>.json`, and a missing entry falls back to English.
|
||||
- **Locale choice.** `?lang=` (one-shot) → stored choice (`gev:locale:v1`) →
|
||||
`navigator.languages` → English. The EN/RU switch in the Visual Presets dock
|
||||
stores the choice and reloads, so nothing re-renders live.
|
||||
- **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`)
|
||||
extracts every message and fails on missing, unused, placeholder-mismatched or
|
||||
wrongly-shaped plural entries.
|
||||
- **Not translated by design:** layer ids, share-link and hash parameters, API
|
||||
fields, voice tool schemas, provider names, feed data (callsigns, vessel and
|
||||
station names) and attribution.
|
||||
- **Gaps.** Strings added after #601's extraction (for example the Street Level
|
||||
panel) are still raw English; there is no lint for unmarked UI text yet.
|
||||
|
||||
## Canonical Docs Order
|
||||
|
||||
|
||||
+77
-181
@@ -1,198 +1,94 @@
|
||||
# Translator's guide (i18n foundation — English shipped)
|
||||
# Translating God's Eye View
|
||||
|
||||
This is the working guide for translating God's Eye View. The i18n
|
||||
foundation ships **English only**: the locale core, the English
|
||||
catalogs, and the parity gates are in, and every additional locale lands
|
||||
as its own stacked PR, one locale at a time, following the
|
||||
[locale-addition recipe](#the-locale-addition-recipe) below. That recipe
|
||||
is the deliberate design artifact of this phase — a locale is not
|
||||
"translated into the app", it is *shipped* through the same gate every
|
||||
time.
|
||||
Shipped languages: **English** (source and fallback) and **Russian** (`ru`).
|
||||
|
||||
## Which locale ships, and which pair is offered
|
||||
## How it works
|
||||
|
||||
One catalog ships: **en** (source of truth and unconditional fallback).
|
||||
Shipping is catalog-driven (`CATALOG_LOCALES` in `src/i18n/locale.js`):
|
||||
while only English ships, the built-in locale pair is en-only and the
|
||||
dock language switch renders a single EN button. Which pair the app
|
||||
*offers* is configuration, not code: `GEV_DEFAULT_LOCALE` and
|
||||
`GEV_SECONDARY_LOCALE` in `.env`, injected into the browser via vite
|
||||
defines. Both values are validated against the shipped catalogs — an
|
||||
invalid, unshipped, or degenerate pair (e.g. the same locale twice)
|
||||
degenerates to the built-in en-only shape with a dev-server-only
|
||||
`console.warn`. English is always resolvable — it is the fallback
|
||||
catalog — even when not part of the configured pair.
|
||||
The English text is the key. There is no English catalog: the code is the source.
|
||||
|
||||
## How the catalog system works
|
||||
|
||||
All application-owned UI text lives in four flat message catalogs (en
|
||||
today; one directory per locale once shipped):
|
||||
|
||||
```text
|
||||
src/i18n/
|
||||
locale.js pair config + resolution + storage + <html lang>/<html dir>
|
||||
index.js catalog registry, t(), Intl formatters, DOM apply
|
||||
locales/en/{shell,cockpit,layers,setup}.js
|
||||
```js
|
||||
t('Save keys'); // plain text
|
||||
t('Saved to {store}', { store }); // named placeholders
|
||||
tn(count, '{count} camera', '{count} cameras'); // count-dependent text
|
||||
msg('Live Flights'); // text kept in data, translated later by t()
|
||||
```
|
||||
|
||||
| Namespace | Surface |
|
||||
| --- | --- |
|
||||
| `shell.*` | Document chrome: title, dock actions, panels, global status chip, sync chips, toasts, locale selector |
|
||||
| `cockpit.*` | Cockpit HUD readouts, Context panel, CCTV scene summary, Display/panel chrome, visual presets, Radio utility |
|
||||
| `layers.*` | Data-layer presentation: layer names, feed-state labels, row meta, vessel/flight/satellite/mission/CCTV/Radio/awareness cards and readouts |
|
||||
| `setup.*` | First-run launcher, key setup (POWER UP), scenes/director status, map-stack chips |
|
||||
```html
|
||||
<span data-i18n>Normal</span>
|
||||
<button title="Pin visual presets" data-i18n-title>…</button>
|
||||
```
|
||||
|
||||
Each catalog file exports `NAMESPACE` and a default map of
|
||||
namespace-relative keys; `mergeNamespace()` prefixes them (`cockpit.…`)
|
||||
and `buildCatalog()` rejects duplicates. English is the default and the
|
||||
fallback locale: a key missing in another locale renders its English
|
||||
value (dev-server-only `console.warn`). The en catalogs hold 912 keys
|
||||
(shell 34, cockpit 369, layers 388, setup 121).
|
||||
`data-i18n` translates the element's own text (put it on the element that holds the text, not on a parent with children). `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute's English value.
|
||||
|
||||
## Key naming
|
||||
Every other language is one flat JSON file, `src/i18n/locales/<code>.json`, keyed by the English text:
|
||||
|
||||
- Markup surfaces: `<namespace>.<surface>.<element>` — e.g.
|
||||
`shell.panels.dataLayers`, `cockpit.readout.altitude`.
|
||||
- Runtime flows/states: `<namespace>.<flow>.<state>` — e.g.
|
||||
`shell.status.loadingLiveData`, `layers.missions.telemetry.km`.
|
||||
- Names are semantic and stable. Never rename, reorder, or renumber an
|
||||
existing key: `data-i18n` attributes and runtime `t()` call sites in other
|
||||
files reference them.
|
||||
```json
|
||||
{
|
||||
"Save keys": "Сохранить ключи",
|
||||
"{count} cameras": {
|
||||
"one": "{count} камера",
|
||||
"few": "{count} камеры",
|
||||
"many": "{count} камер",
|
||||
"other": "{count} камеры"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## The locale-addition recipe
|
||||
A `tn()` entry is keyed by the English plural form and holds one string per `Intl.PluralRules` category of that language. A missing entry falls back to English.
|
||||
|
||||
Every locale PR follows the same steps, in this order. Nothing here is
|
||||
optional — the gates below fail the build if a step is skipped.
|
||||
The page picks its language from `?lang=`, then the choice stored by the EN/RU switch in the Visual Presets dock, then the browser languages, then English. Switching reloads the page.
|
||||
|
||||
1. **Catalogs.** Copy `src/i18n/locales/en/` to
|
||||
`src/i18n/locales/<code>/` and translate all four files
|
||||
(`<code>` is the primary language tag: `es`, not `es-419`). Keep the
|
||||
en key order, keep key names byte-identical, and translate VALUES
|
||||
only. A locale may never carry a key en lacks.
|
||||
2. **Registration.** In `src/i18n/index.js`: add the four import lines
|
||||
and one `<CODE>_NAMESPACES` array, then one entry in the `CATALOGS`
|
||||
map. Registration is append-only; nothing else in the file changes.
|
||||
3. **Shipping.** Append `'<code>'` to `CATALOG_LOCALES` in
|
||||
`src/i18n/locale.js`. This single constant is what makes the locale
|
||||
normalizable-to-shipped: pair config, `?lang=`, storage, and the
|
||||
selector all read from it. Regional variants (`es-MX`, `es_419`)
|
||||
already normalize — locale-code hygiene is shipped ahead of the
|
||||
locales on purpose.
|
||||
4. **Selector labels.** Append
|
||||
`'locale.<code>.ariaLabel': 'Switch to <Language>'` to the `shell`
|
||||
catalog of EVERY shipped locale (en included — the en catalog names
|
||||
the new button; the new catalog names the English one).
|
||||
`markupCoverage.test.mjs` requires a label for every shipped code in
|
||||
every catalog, so this cannot be forgotten.
|
||||
5. **Plurals.** Plural entries are variant objects carrying a `{count}`
|
||||
placeholder, selected per active locale through `Intl.PluralRules`.
|
||||
English entries use `{ one, other }`. Your locale must keep every en
|
||||
variant and may add ONLY categories `Intl.PluralRules` reports valid
|
||||
for it (e.g. a Slavic locale adds `few`/`many`) —
|
||||
`catalog.test.mjs` enforces this shape against the live ICU data.
|
||||
Pin your locale's real-catalog plural agreement in
|
||||
`src/i18n/i18n.test.mjs` (one/few/many/other counts where relevant).
|
||||
6. **Layout.** Prefer concise glossary-approved copy over CSS surgery;
|
||||
fixed-width `nowrap` panels must be checked at narrow viewports. A
|
||||
locale-scoped wrap rule (`html[lang='<code>'] .cctv-controls {
|
||||
flex-wrap: wrap; }` in `src/ui/styles/cctv.css`) is added ONLY when
|
||||
the locale demonstrably clips, with a comment recording the measured
|
||||
widths — the English layout stays pixel-identical, and
|
||||
`repairPass.test.mjs` rejects a wrap rule shipping ahead of its
|
||||
locale.
|
||||
7. **Gates.** `node --test src/i18n/` must be green with the strict
|
||||
parity gate ON: exact key-set equality with en, identical
|
||||
placeholder names, plural-shape superset. While a new catalog is a
|
||||
staged untranslated seed, CI may run with
|
||||
`GEV_I18N_REQUIRE_FULL_LOCALE_PARITY=0` (subset mode) — record every
|
||||
such flip here, and record the flip back.
|
||||
8. **Docs.** Add the locale's section + glossary to this file, note it
|
||||
in `docs/CURRENT-STATE.md` (Internationalization) and `CHANGELOG.md`,
|
||||
and update the shipped-locales wording in `.env.example`.
|
||||
|
||||
## Append-only rules
|
||||
|
||||
- New keys are appended to the END of the namespace file in EVERY
|
||||
shipped locale directory in the same change. Never edit another
|
||||
namespace's files, and never touch the registration arrays in
|
||||
`src/i18n/index.js` without re-reading this guide first.
|
||||
- A fifth namespace is a last resort (prefer fitting one of the four); its
|
||||
registration recipe is the same append-only pattern as a locale (step 2
|
||||
of the recipe above), applied to every shipped locale at once.
|
||||
- Parity is enforced by tests, not goodwill: no locale may carry a key en
|
||||
lacks, and every shared key must keep identical placeholder names
|
||||
and a plural-variant set that includes every en variant plus only
|
||||
categories `Intl.PluralRules` reports for that locale
|
||||
(`src/i18n/catalog.test.mjs`).
|
||||
|
||||
## Placeholder, plural, and typography rules
|
||||
|
||||
- Interpolation uses named `{camelCase}` placeholders:
|
||||
`'Flying to {place}…'`. The **same placeholder names must exist in
|
||||
every locale** — the parity gate fails on a renamed `{name}` because
|
||||
it would break interpolation at runtime.
|
||||
- Locale-aware numbers, dates, and relative times go through the
|
||||
`formatNumber` / `formatDate` / `formatRelativeTime` helpers in
|
||||
`src/i18n/index.js` — do not hand-format in a catalog call site.
|
||||
- Style: the English catalog is ALL-CAPS for tactical labels, readouts,
|
||||
and status text; translations follow the same convention in their
|
||||
script's norms. The `·` separator used between metadata segments is
|
||||
part of the surface style and is reproduced in translations.
|
||||
- Instrument codes stay identical across languages; units (KTS, FT, km,
|
||||
km²) are never translated.
|
||||
- Romance languages run ~25% longer than English and Slavic languages
|
||||
compound that on multi-word tactical labels — plan string budgets and
|
||||
check fixed-width panels before review (see step 6 above).
|
||||
|
||||
## Keep-English boundary (summary)
|
||||
|
||||
Never catalog these — they are contracts, not copy:
|
||||
|
||||
- Internal layer IDs and registry keys (`military-installations`,
|
||||
`ais-live-vessels`, stack ids).
|
||||
- Status enums and machine values (`FEED_STATE_LABELS` keys, lifecycle
|
||||
states, detection/context modes) — translate only the final label at
|
||||
the presentation boundary.
|
||||
- URL/hash contracts: share-link parameters, `?welcome=`, `?lang=`,
|
||||
storage schemas.
|
||||
- API fields, voice tool names/schemas, `/api/setup/*` payloads.
|
||||
- Provider/dataset/product names, callsigns, vessel/satellite/place
|
||||
names from data, news headlines, license/source attributions.
|
||||
- Console diagnostics, QA pin scripts, and existing exact-English test
|
||||
expectations.
|
||||
- User-authored content (annotations, labels drawn from live data).
|
||||
|
||||
Adjudicated exceptions live in "Internationalization" in
|
||||
[`docs/CURRENT-STATE.md`](./CURRENT-STATE.md) (accepted deferrals — do not
|
||||
"fix" them silently).
|
||||
|
||||
## Glossary
|
||||
|
||||
Approved recurring terms per locale. Each locale PR appends its
|
||||
glossary here (English term · translation · catalog evidence) so later
|
||||
translators reuse approved copy verbatim. None yet — the first entry
|
||||
arrives with the first translated locale.
|
||||
|
||||
## Running the i18n test gates
|
||||
## The check
|
||||
|
||||
```sh
|
||||
node --test src/i18n/ # 40 tests: core + pair config, catalog parity, markup coverage, repair-pass anchors
|
||||
npm test # full suite (see below for the known environmental caveat)
|
||||
npm run i18n # what is missing, unused or broken, per language
|
||||
npm run i18n -- --missing ru # missing ru entries as JSON, ready to translate
|
||||
npm run i18n -- --prune # drop entries the code no longer uses
|
||||
```
|
||||
|
||||
- `src/i18n/i18n.test.mjs` — resolution precedence, guarded storage,
|
||||
fallback, interpolation, plural selection (contract + synthetic
|
||||
missing-category degradation; real-catalog plural pins arrive with
|
||||
each locale PR), DOM application.
|
||||
- `src/i18n/catalog.test.mjs` — key + placeholder + plural-shape parity for
|
||||
every shipped locale against en, strict `REQUIRE_FULL_PARITY` gate
|
||||
(vacuous while only en ships; each locale PR meets it from day one).
|
||||
- `src/i18n/markupCoverage.test.mjs` — every `data-i18n*` attribute in
|
||||
`index.html` must resolve in every shipped catalog; unknown attribute
|
||||
spellings fail loudly.
|
||||
- `src/i18n/repairPass.test.mjs` — byte-identity anchors for English
|
||||
literals; per-locale value anchors arrive with each locale PR.
|
||||
`npm test` runs the same check (`src/i18n/catalog.test.mjs`). It fails when a translation is missing, when an entry is no longer used, when placeholders differ from the English, or when a plural entry lacks a form.
|
||||
|
||||
Full-suite caveat: `src/devFreshDotenv.test.mjs`'s external-keys provenance
|
||||
test fails when a provider key (e.g. `OPENAI_API_KEY`) is exported in the
|
||||
running shell — an environmental false positive unrelated to i18n.
|
||||
Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune.
|
||||
|
||||
## Fixing a translation
|
||||
|
||||
Edit `src/i18n/locales/<code>.json` and run `npm run i18n`. To see your change, open the app with `?lang=<code>`.
|
||||
|
||||
## Adding a language
|
||||
|
||||
1. Create `src/i18n/locales/<code>.json`, fill it from `npm run i18n -- --missing <code>` (start from `{}`), and translate every entry.
|
||||
2. Import it in `src/i18n/index.js` and add it to `CATALOGS`.
|
||||
3. Add the code to `LOCALES` and its own-language name to `LOCALE_NAMES` in `src/i18n/locale.js`. Right-to-left languages also go in `RTL_LOCALES`.
|
||||
4. Add `src/i18n/locales/<code>.json` next to `ru.json` in `scripts/package-boundaries.json`.
|
||||
5. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths.
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep every `{placeholder}` exactly as in the English; you may move it.
|
||||
- UPPERCASE English stays UPPERCASE. Short labels, chips and HUD readouts should stay short; prefer an abbreviation over a wrapped line.
|
||||
- Keep the separators `·`, `—`, `→`, `↗`, `…`.
|
||||
- Leave product, provider and data names in their original form: Google Maps, Cesium ion, OpenAI, ChatGPT, NASA FIRMS, USGS, OpenSky, ADS-B, AIS, CCTV, HUD, TomTom, OpenStreetMap, Mapillary, and keyboard keys (Space, Esc, H, 1–9).
|
||||
- Units follow the language's convention (Russian: км, м/с, уз, фт).
|
||||
|
||||
## Never translated
|
||||
|
||||
These are contracts, not copy: layer ids and registry keys, status enums and other machine values (translate only the label shown for them), share-link and hash parameters, `?lang=`, storage schemas, API fields, voice tool names and schemas, provider and dataset names, data from feeds (callsigns, vessel, satellite, station and place names, headlines), attribution, console diagnostics, and user-authored content.
|
||||
|
||||
## Russian glossary
|
||||
|
||||
| English | Русский |
|
||||
| ------------------------------- | ---------------------------------- |
|
||||
| layer / data layer | слой / слой данных |
|
||||
| feed | поток данных (short: поток) |
|
||||
| source | источник |
|
||||
| LIVE (status) / live data | ОНЛАЙН / в реальном времени |
|
||||
| SIMULATED | СИМУЛЯЦИЯ |
|
||||
| stale / degraded / unavailable | устарел / с перебоями / недоступен |
|
||||
| key (API key) / POWER UP | ключ / ЗАПУСК |
|
||||
| aircraft / flight / vessel | самолёт / рейс / судно |
|
||||
| launch (rocket) / space mission | пуск / космическая миссия |
|
||||
| contact / tracking | контакт / отслеживание |
|
||||
| installation | объект |
|
||||
| scene / shot / recipe | сцена / кадр / рецепт |
|
||||
| HDG / BRG / CRS / FOV / DEST | КУРС / ПЕЛ. / ПУТЬ / ОБЗОР / НАЗН. |
|
||||
| PREV / NEXT | ПРЕД. / СЛЕД. |
|
||||
@@ -35,6 +35,7 @@
|
||||
"doctor": "node scripts/setup-doctor.mjs",
|
||||
"format": "node scripts/format.mjs --write",
|
||||
"format:check": "node scripts/format.mjs --check",
|
||||
"i18n": "node scripts/i18n.mjs",
|
||||
"layer-token:next": "node scripts/next-layer-state-token.mjs",
|
||||
"layer-token:check": "node scripts/check-layer-state-tokens.mjs",
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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
@@ -421,10 +421,7 @@
|
||||
"src/localRequestGate.mjs",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -448,10 +445,7 @@
|
||||
"src/data/layerSnapshot.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -465,10 +459,7 @@
|
||||
"src/app/stateChannel.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -482,10 +473,7 @@
|
||||
"src/ui/radioTunerModel.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -501,10 +489,7 @@
|
||||
"src/ui/cctvPresentation.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -525,10 +510,7 @@
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -559,10 +541,7 @@
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -574,10 +553,7 @@
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -719,10 +695,7 @@
|
||||
"src/data/cameraArrival.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -831,10 +804,7 @@
|
||||
"src/data/cameraArrival.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -891,10 +861,7 @@
|
||||
"src/data/modelEligibility.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -936,10 +903,7 @@
|
||||
"src/data/modelEligibility.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -995,10 +959,7 @@
|
||||
"src/data/inputOwnership.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1047,10 +1008,7 @@
|
||||
"src/data/inputOwnership.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1079,10 +1037,7 @@
|
||||
"src/data/inputOwnership.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium", "satellite.js"]
|
||||
},
|
||||
@@ -1112,10 +1067,7 @@
|
||||
"src/data/inputOwnership.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1153,10 +1105,7 @@
|
||||
"src/data/infrastructureOverlayEntry.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
@@ -1186,10 +1135,7 @@
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1233,10 +1179,7 @@
|
||||
"src/layers/traffic/roadModes.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium", "pbf", "@mapbox/vector-tile"]
|
||||
},
|
||||
@@ -1262,10 +1205,7 @@
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1309,10 +1249,7 @@
|
||||
"src/layers/cctv/sourcePolicy.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["hls.js", "cesium"]
|
||||
},
|
||||
@@ -1410,10 +1347,7 @@
|
||||
"src/data/inputOwnership.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1433,10 +1367,7 @@
|
||||
"src/data/inputOwnership.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1488,10 +1419,7 @@
|
||||
"src/maps/imageryHost.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1541,10 +1469,7 @@
|
||||
"src/voice/pointerReticle.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -2351,10 +2276,7 @@
|
||||
"src/app/embed.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -2879,10 +2801,7 @@
|
||||
"src/sources/infrastructureData.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -2987,10 +2906,7 @@
|
||||
"src/ui/uiLifetime.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -3021,10 +2937,7 @@
|
||||
"src/ui/visualSettings.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -3047,10 +2960,7 @@
|
||||
"src/ui/uiLifetime.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -3176,10 +3086,7 @@
|
||||
"src/voice/pointerReticle.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/en/cockpit.js",
|
||||
"src/i18n/locales/en/layers.js",
|
||||
"src/i18n/locales/en/setup.js",
|
||||
"src/i18n/locales/en/shell.js"
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
|
||||
@@ -23,8 +23,6 @@ export default defineConfig(({ command, mode }) => {
|
||||
],
|
||||
googleApiKey: process.env.GOOGLE_MAPS_API_KEY,
|
||||
cesiumToken: process.env.CESIUM_ION_TOKEN,
|
||||
defaultLocale: process.env.GEV_DEFAULT_LOCALE ?? '',
|
||||
secondaryLocale: process.env.GEV_SECONDARY_LOCALE ?? '',
|
||||
mapillaryToken: process.env.MAPILLARY_CLIENT_TOKEN,
|
||||
host: process.env.HOST,
|
||||
port: process.env.PORT,
|
||||
|
||||
Vendored
+2
-2
@@ -42,10 +42,10 @@ export function createApplicationControls({
|
||||
|
||||
// If no share link state, do default fly-to Austin
|
||||
if (!styleManager.hasShareState) {
|
||||
loaderStatus.textContent = t('shell.loading.status.flying');
|
||||
loaderStatus.textContent = t('Flying to Austin, TX...');
|
||||
defer(flyToAustin(viewer));
|
||||
} else {
|
||||
loaderStatus.textContent = t('shell.loading.status.restoring');
|
||||
loaderStatus.textContent = t('Restoring shared view...');
|
||||
}
|
||||
|
||||
return { styleManager, weatherEffects, cockpitCloudEffects };
|
||||
|
||||
+10
-7
@@ -43,7 +43,7 @@ export async function createApplicationScene({
|
||||
else window.__GOOGLE_MAPS_API_KEY__ = previousKey;
|
||||
});
|
||||
}
|
||||
loaderStatus.textContent = t('shell.loading.status.configuring');
|
||||
loaderStatus.textContent = t('Configuring viewer...');
|
||||
// Provider attribution stays visible, including clean-view and recording.
|
||||
const creditContainer = document.createElement('div');
|
||||
creditContainer.id = 'cesium-credits';
|
||||
@@ -62,8 +62,8 @@ export async function createApplicationScene({
|
||||
configureCreditKeyboardAccess(document);
|
||||
loaderStatus.textContent =
|
||||
googleApiKey || googleTokens || cesiumToken
|
||||
? t('shell.loading.status.tilesGoogle')
|
||||
: t('shell.loading.status.tilesKeyless');
|
||||
? t('Loading Google 3D Tiles...')
|
||||
: t('Loading the keyless globe...');
|
||||
const photoreal = await loadPhotorealisticTileset(Cesium, {
|
||||
googleApiKey,
|
||||
googleTokens,
|
||||
@@ -91,14 +91,17 @@ export async function createApplicationScene({
|
||||
tileError,
|
||||
);
|
||||
const tileErrorDetail = describeError(tileError);
|
||||
loaderStatus.textContent = t('shell.loading.status.tilesUnavailable', {
|
||||
detail: tileErrorDetail,
|
||||
});
|
||||
loaderStatus.textContent = t(
|
||||
'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...',
|
||||
{
|
||||
detail: tileErrorDetail,
|
||||
},
|
||||
);
|
||||
}
|
||||
viewer.scene.globe.show = true;
|
||||
}
|
||||
|
||||
loaderStatus.textContent = t('shell.loading.status.systems');
|
||||
loaderStatus.textContent = t('Initializing systems...');
|
||||
|
||||
const mapStackController = new MapController(viewer, {
|
||||
requestRender: governorRequestRender,
|
||||
|
||||
@@ -21,7 +21,7 @@ export function configureCreditKeyboardAccess(root = document) {
|
||||
expand.setAttribute('aria-expanded', 'false');
|
||||
close.setAttribute('role', 'button');
|
||||
close.setAttribute('tabindex', '0');
|
||||
close.setAttribute('aria-label', t('shell.credits.closeAria'));
|
||||
close.setAttribute('aria-label', t('Close data attribution'));
|
||||
|
||||
if (expand.dataset.gevKeyboardReady === 'true') return true;
|
||||
expand.dataset.gevKeyboardReady = 'true';
|
||||
|
||||
+1669
-573
File diff suppressed because it is too large.
Load diff
@@ -84,14 +84,14 @@ export function summarizeAwarenessCohort(
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: t('layers.awareness.reason.feedUnavailable'),
|
||||
reason: t('feed unavailable'),
|
||||
};
|
||||
if (stale)
|
||||
return {
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: t('layers.awareness.reason.feedStale'),
|
||||
reason: t('feed stale'),
|
||||
};
|
||||
const normalized = (Array.isArray(items) ? items : [])
|
||||
.filter(
|
||||
@@ -108,8 +108,8 @@ export function summarizeAwarenessCohort(
|
||||
count: normalized.length,
|
||||
nearest: normalized.slice(0, limit),
|
||||
reason: normalized.length
|
||||
? t('layers.awareness.reason.nearby')
|
||||
: t('layers.awareness.reason.none'),
|
||||
? t('observed or mapped nearby context')
|
||||
: t('no observed or mapped objects in current feeds'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+13
-11
@@ -17,7 +17,7 @@
|
||||
* for), then the constellations users can actually name, then the grab-bag.
|
||||
*/
|
||||
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Class registry. `label` is the card/legend token, `color` the point color,
|
||||
@@ -84,24 +84,26 @@ export const SATELLITE_CLASS_ORDER = Object.freeze([
|
||||
// translate through these keys at the presentation boundary.
|
||||
const CLASS_DISPLAY_KEYS = Object.freeze({
|
||||
station: Object.freeze({
|
||||
label: 'layers.satellites.class.station',
|
||||
blurb: 'layers.satellites.class.stationBlurb',
|
||||
label: msg('STATION'),
|
||||
blurb: msg('Crewed stations and their visiting vehicles'),
|
||||
}),
|
||||
nav: Object.freeze({
|
||||
label: 'layers.satellites.class.nav',
|
||||
blurb: 'layers.satellites.class.navBlurb',
|
||||
label: msg('NAV'),
|
||||
blurb: msg('GNSS navigation — GPS, GLONASS, Galileo'),
|
||||
}),
|
||||
geo: Object.freeze({
|
||||
label: 'layers.satellites.class.geo',
|
||||
blurb: 'layers.satellites.class.geoBlurb',
|
||||
label: msg('GEO'),
|
||||
blurb: msg(
|
||||
'Geostationary belt — comms and weather, fixed over the equator',
|
||||
),
|
||||
}),
|
||||
visual: Object.freeze({
|
||||
label: 'layers.satellites.class.visual',
|
||||
blurb: 'layers.satellites.class.visualBlurb',
|
||||
label: msg('VISUAL'),
|
||||
blurb: msg('Brightest naked-eye objects — CelesTrak visual group'),
|
||||
}),
|
||||
comms: Object.freeze({
|
||||
label: 'layers.satellites.class.comms',
|
||||
blurb: 'layers.satellites.class.commsBlurb',
|
||||
label: msg('COMMS'),
|
||||
blurb: msg('Broadband constellation shell — shown only in DENSE mode'),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+20
-20
@@ -4,7 +4,7 @@
|
||||
* after the vessel cards moved out of their dedicated canvas renderer.
|
||||
*/
|
||||
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
|
||||
export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels';
|
||||
/** Existing selector grid size; one ambient winner is retained per cell. */
|
||||
@@ -70,25 +70,25 @@ const NUMERIC_TYPE_FAMILIES = {
|
||||
// machine values — TYPE_STYLES matches on them and tests pin them — so card
|
||||
// text translates only through this map at the presentation boundary.
|
||||
const TYPE_DISPLAY_KEYS = Object.freeze({
|
||||
FISHING: 'layers.vessel.type.fishing',
|
||||
TOWING: 'layers.vessel.type.towing',
|
||||
DREDGER: 'layers.vessel.type.dredger',
|
||||
'DIVE OPS': 'layers.vessel.type.diveOps',
|
||||
MILITARY: 'layers.vessel.type.military',
|
||||
SAILING: 'layers.vessel.type.sailing',
|
||||
PLEASURE: 'layers.vessel.type.pleasure',
|
||||
PILOT: 'layers.vessel.type.pilot',
|
||||
SAR: 'layers.vessel.type.sar',
|
||||
TUG: 'layers.vessel.type.tug',
|
||||
'PORT TENDER': 'layers.vessel.type.portTender',
|
||||
'ANTI-POLLUTION': 'layers.vessel.type.antiPollution',
|
||||
'LAW ENFORCE': 'layers.vessel.type.lawEnforce',
|
||||
MEDICAL: 'layers.vessel.type.medical',
|
||||
'HIGH-SPEED': 'layers.vessel.type.highSpeed',
|
||||
PASSENGER: 'layers.vessel.type.passenger',
|
||||
CARGO: 'layers.vessel.type.cargo',
|
||||
TANKER: 'layers.vessel.type.tanker',
|
||||
OTHER: 'layers.vessel.type.other',
|
||||
FISHING: msg('FISHING'),
|
||||
TOWING: msg('TOWING'),
|
||||
DREDGER: msg('DREDGER'),
|
||||
'DIVE OPS': msg('DIVE OPS'),
|
||||
MILITARY: msg('MILITARY'),
|
||||
SAILING: msg('SAILING'),
|
||||
PLEASURE: msg('PLEASURE'),
|
||||
PILOT: msg('PILOT'),
|
||||
SAR: msg('SAR'),
|
||||
TUG: msg('TUG'),
|
||||
'PORT TENDER': msg('PORT TENDER'),
|
||||
'ANTI-POLLUTION': msg('ANTI-POLLUTION'),
|
||||
'LAW ENFORCE': msg('LAW ENFORCE'),
|
||||
MEDICAL: msg('MEDICAL'),
|
||||
'HIGH-SPEED': msg('HIGH-SPEED'),
|
||||
PASSENGER: msg('PASSENGER'),
|
||||
CARGO: msg('CARGO'),
|
||||
TANKER: msg('TANKER'),
|
||||
OTHER: msg('OTHER'),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
+22
-12
@@ -21,7 +21,7 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
||||
// Choosing a mission is deliberately NOT durable suppression: picking a mission
|
||||
// is enthusiasm, not "never show me this again".
|
||||
|
||||
import { t } from './i18n/index.js';
|
||||
import { t, msg } from './i18n/index.js';
|
||||
|
||||
/** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */
|
||||
export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1';
|
||||
@@ -39,9 +39,9 @@ export const ENVIRONMENTAL_LABEL_CHOICE = 'ENVIRONMENTAL';
|
||||
// Catalog keys per label choice. Resolved through t() at CALL time (init),
|
||||
// never at module load — the locale is not resolved until main.js's body runs.
|
||||
const ENVIRONMENTAL_LABEL_KEYS = Object.freeze({
|
||||
ENVIRONMENTAL: 'setup.firstRun.environmentalTitle.environmental',
|
||||
EARTH_WATCH: 'setup.firstRun.environmentalTitle.earthWatch',
|
||||
ACTIVE_EVENTS: 'setup.firstRun.environmentalTitle.activeEvents',
|
||||
ENVIRONMENTAL: msg('ENVIRONMENTAL'),
|
||||
EARTH_WATCH: msg('EARTH WATCH'),
|
||||
ACTIVE_EVENTS: msg('ACTIVE EVENTS'),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -98,12 +98,12 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
|
||||
contacts: Object.freeze({
|
||||
kind: 'context',
|
||||
contextMode: 'contacts',
|
||||
busyKey: 'setup.firstRun.busy.contacts',
|
||||
busyKey: msg('Starting live contacts…'),
|
||||
}),
|
||||
'space-missions': Object.freeze({
|
||||
kind: 'context',
|
||||
contextMode: 'space-missions',
|
||||
busyKey: 'setup.firstRun.busy.spaceMissions',
|
||||
busyKey: msg('Opening space missions…'),
|
||||
}),
|
||||
environmental: Object.freeze({
|
||||
kind: 'globe',
|
||||
@@ -121,7 +121,7 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
|
||||
// before a launch. LEDGERED post-launch. Until it lands, keyless visitors
|
||||
// are judged on the layer row, which tells them the truth.
|
||||
layerIds: Object.freeze(['earthquakes', 'local-firms']),
|
||||
busyKey: 'setup.firstRun.busy.environmental',
|
||||
busyKey: msg('Scanning active events…'),
|
||||
}),
|
||||
explore: Object.freeze({ kind: 'none' }),
|
||||
});
|
||||
@@ -375,9 +375,14 @@ export function initFirstRunExperience({
|
||||
'[data-first-run-environmental-title] + small',
|
||||
);
|
||||
if (environmentalSub)
|
||||
environmentalSub.textContent = t('setup.firstRun.choice.environmentalSub');
|
||||
environmentalSub.textContent = t(
|
||||
'Live earthquakes and active fires, from USGS and NASA',
|
||||
);
|
||||
const description = root.querySelector('#first-run-description');
|
||||
if (description) description.textContent = t('setup.firstRun.description');
|
||||
if (description)
|
||||
description.textContent = t(
|
||||
'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
|
||||
);
|
||||
|
||||
const status = root.querySelector('[data-first-run-status]');
|
||||
const suppressBox = root.querySelector('[data-first-run-suppress]');
|
||||
@@ -461,7 +466,7 @@ export function initFirstRunExperience({
|
||||
if (!status) return;
|
||||
if (next)
|
||||
status.textContent = t(
|
||||
FIRST_RUN_MISSIONS[choice]?.busyKey || 'setup.firstRun.busy.working',
|
||||
FIRST_RUN_MISSIONS[choice]?.busyKey || msg('Working…'),
|
||||
);
|
||||
else if (status.dataset.sticky !== 'true')
|
||||
status.textContent = defaultStatus;
|
||||
@@ -512,7 +517,10 @@ export function initFirstRunExperience({
|
||||
Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
|
||||
if (status) {
|
||||
status.dataset.sticky = 'true';
|
||||
status.textContent = t('setup.firstRun.status.failed', { detail });
|
||||
status.textContent = t(
|
||||
'Could not open that mission{detail}. Retry or explore manually.',
|
||||
{ detail },
|
||||
);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
@@ -528,7 +536,9 @@ export function initFirstRunExperience({
|
||||
if (box) box.checked = !wanted;
|
||||
if (!status) return;
|
||||
status.dataset.sticky = 'true';
|
||||
status.textContent = t('setup.firstRun.status.storageBlocked');
|
||||
status.textContent = t(
|
||||
'This browser is blocking storage, so that could not be saved.',
|
||||
);
|
||||
};
|
||||
|
||||
const keyboard = createSurfaceKeyboard({
|
||||
|
||||
+4
-4
@@ -202,15 +202,15 @@ export class IntelHUD {
|
||||
<div class="hud-system">${this._missionId} ${this._sensorId}</div>
|
||||
<div class="hud-mode" id="hud-mode">NORMAL</div>
|
||||
<div class="hud-summary-wrap">
|
||||
<div class="hud-summary-label">${t('cockpit.hud.summaryLabel')}</div>
|
||||
<div class="hud-summary" id="hud-summary">${t('cockpit.hud.summaryAwaiting')}</div>
|
||||
<div class="hud-summary-label">${t('SUMMARY')}</div>
|
||||
<div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hud-corner hud-top-right">
|
||||
<div class="hud-content" style="text-align:right">
|
||||
<div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('cockpit.hud.recLabel')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div>
|
||||
<div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div>
|
||||
<div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div>
|
||||
</div>
|
||||
<div class="hud-bracket">┐</div>
|
||||
@@ -635,7 +635,7 @@ export class IntelHUD {
|
||||
*/
|
||||
_composeSummary() {
|
||||
const m = this._latestMetrics;
|
||||
if (!m) return t('cockpit.hud.summaryAwaiting');
|
||||
if (!m) return t('Awaiting telemetry...');
|
||||
|
||||
const modeEl = document.getElementById('hud-mode');
|
||||
const modeLabel = modeEl?.textContent || 'NORMAL';
|
||||
|
||||
+43
-184
@@ -1,198 +1,57 @@
|
||||
// Catalog parity gates: every shipped non-English locale (the es follow-up,
|
||||
// then one PR per further locale) is checked against en — it may never LEAD en
|
||||
// (no extra keys), every shared key must keep identical placeholder names,
|
||||
// and plural entries must carry every en variant plus only categories
|
||||
// Intl.PluralRules reports valid for that locale (see assertVariantSuperset
|
||||
// below); the strict gate demands exact key-set equality so a forgotten
|
||||
// translation cannot ship silently behind the English fallback. While English
|
||||
// is the only shipped locale these loops are vacuous — the gate machinery
|
||||
// stays so each locale PR meets it from day one.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getCatalog, mergeNamespace } from './index.js';
|
||||
import { CATALOG_LOCALES } from './locale.js';
|
||||
|
||||
/*
|
||||
* PARITY FLIP — how the strict gate is previewed or relaxed.
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* REQUIRE_FULL_PARITY is on by default: every shipped locale must hold the
|
||||
* exact en key set. While a NEW catalog is still an untranslated seed that
|
||||
* lags en, run CI with GEV_I18N_REQUIRE_FULL_LOCALE_PARITY=0 to drop back to
|
||||
* the subset rules (a locale may be a subset of en, never a superset). The
|
||||
* pre-generalization name GEV_I18N_REQUIRE_FULL_ES_PARITY is still honored as
|
||||
* an alias. Record every flip in docs/TRANSLATORS.md.
|
||||
*/
|
||||
const PARITY_OPT_OUT = process.env.GEV_I18N_REQUIRE_FULL_LOCALE_PARITY
|
||||
?? process.env.GEV_I18N_REQUIRE_FULL_ES_PARITY;
|
||||
const REQUIRE_FULL_PARITY = PARITY_OPT_OUT !== '0';
|
||||
import {
|
||||
checkCatalog,
|
||||
extractMessages,
|
||||
readCatalog,
|
||||
shippedLocales,
|
||||
} from '../../scripts/i18n.mjs';
|
||||
import { LOCALES } from './locale.js';
|
||||
|
||||
const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g;
|
||||
const messages = extractMessages();
|
||||
|
||||
/** Placeholder names used by one catalog entry (string or plural variants). */
|
||||
function placeholdersOf(entry) {
|
||||
const patterns = typeof entry === 'string' ? [entry] : Object.values(entry || {});
|
||||
const names = new Set();
|
||||
for (const pattern of patterns) {
|
||||
if (typeof pattern !== 'string') continue;
|
||||
for (const match of pattern.matchAll(PLACEHOLDER_PATTERN)) names.add(match[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/*
|
||||
* PLURAL-SHAPE PARITY, GENERALIZED FOR MULTI-CATEGORY LOCALES
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* en plural entries carry { one, other } — the only cardinal categories
|
||||
* Intl.PluralRules reports for English. Slavic locales such as ru/uk select
|
||||
* one/few/many/other, so exact-shape equality with en would forbid their
|
||||
* grammars. The rule is now, per locale and shared key:
|
||||
* (a) the entry must carry EVERY variant en has (they are mandatory — the
|
||||
* runtime reads entry.<category> ?? entry.other, so dropping an en
|
||||
* variant strands the fallback), and
|
||||
* (b) every variant added beyond en's must be a cardinal category
|
||||
* Intl.PluralRules reports VALID for that locale
|
||||
* (resolvedOptions().validCategories, spelled pluralCategories on
|
||||
* older runtimes; e.g. 'few'/'many' are valid for ru/uk but not en).
|
||||
* en itself can never grow a variant (English's valid set is exactly
|
||||
* one/other), and a typo'd variant like 'xother' fails loudly for any
|
||||
* locale. The runtime degrades gracefully the other way: an entry lacking a
|
||||
* category its locale would select renders entry.other (selectPluralPattern
|
||||
* in src/i18n/index.js; pinned in i18n.test.mjs).
|
||||
*/
|
||||
|
||||
/** Cardinal plural categories Intl may select for a locale. */
|
||||
function validPluralCategories(locale) {
|
||||
const { validCategories, pluralCategories } = new Intl.PluralRules(locale).resolvedOptions();
|
||||
// The property was renamed pluralCategories → validCategories in newer
|
||||
// ECMAScript drafts; read whichever the running ICU exposes.
|
||||
const categories = validCategories ?? pluralCategories;
|
||||
assert.ok(Array.isArray(categories), `Intl.PluralRules(${locale}) exposes no category set`);
|
||||
return new Set(categories);
|
||||
}
|
||||
|
||||
/** Assert the plural-shape rule for one locale entry against its en twin. */
|
||||
function assertVariantSuperset(localeEntry, enEntry, locale, key) {
|
||||
assert.equal(
|
||||
typeof localeEntry,
|
||||
typeof enEntry,
|
||||
`variant-shape drift on ${locale}:${key}: string ↔ plural-object mismatch`,
|
||||
);
|
||||
if (typeof enEntry === 'string') return;
|
||||
const enVariants = Object.keys(enEntry);
|
||||
const variants = Object.keys(localeEntry);
|
||||
const missing = enVariants.filter((variant) => !variants.includes(variant));
|
||||
test('every catalog file is a shipped locale and every non-English locale has a catalog', () => {
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`${locale}:${key} must keep every en variant (${enVariants.join(',')})`,
|
||||
shippedLocales().sort(),
|
||||
LOCALES.filter((locale) => locale !== 'en').sort(),
|
||||
);
|
||||
const valid = validPluralCategories(locale);
|
||||
const invalid = variants.filter((variant) => !enVariants.includes(variant) && !valid.has(variant));
|
||||
assert.deepEqual(
|
||||
invalid,
|
||||
[],
|
||||
`${locale}:${key} adds categories Intl.PluralRules(${locale}) never selects: ${invalid.join(',')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const enKeys = Object.keys(getCatalog('en')).sort();
|
||||
// Every shipped non-en locale with its sorted key list.
|
||||
const localeKeys = new Map(
|
||||
CATALOG_LOCALES
|
||||
.filter((locale) => locale !== 'en')
|
||||
.map((locale) => [locale, Object.keys(getCatalog(locale)).sort()]),
|
||||
);
|
||||
|
||||
test('every shipped locale ships a merged catalog', () => {
|
||||
// English only in the foundation state; a locale PR appends its code here
|
||||
// alongside its catalog files (docs/TRANSLATORS.md).
|
||||
assert.deepEqual([...CATALOG_LOCALES].sort(), ['en']);
|
||||
for (const locale of CATALOG_LOCALES) {
|
||||
assert.ok(getCatalog(locale), `merged catalog for ${locale}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no locale carries keys that en does not have', () => {
|
||||
for (const [locale, keys] of localeKeys) {
|
||||
const extras = keys.filter((key) => !enKeys.includes(key));
|
||||
assert.deepEqual(
|
||||
extras,
|
||||
[],
|
||||
`extra ${locale} keys leak untranslated-only surfaces: ${extras.join(', ')}`,
|
||||
for (const locale of shippedLocales()) {
|
||||
test(`${locale} catalog matches the code (npm run i18n)`, () => {
|
||||
const { missing, stale, errors } = checkCatalog(
|
||||
locale,
|
||||
readCatalog(locale),
|
||||
messages,
|
||||
);
|
||||
}
|
||||
});
|
||||
assert.deepEqual(
|
||||
{ missing, stale, errors },
|
||||
{ missing: [], stale: [], errors: [] },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('placeholder names match en; plural variants form an en-superset of Intl-valid categories', () => {
|
||||
const enCatalog = getCatalog('en');
|
||||
for (const [locale, keys] of localeKeys) {
|
||||
const catalog = getCatalog(locale);
|
||||
for (const key of keys) {
|
||||
assert.deepEqual(
|
||||
[...placeholdersOf(catalog[key])].sort(),
|
||||
[...placeholdersOf(enCatalog[key])].sort(),
|
||||
`placeholder drift on ${locale}:${key}: a renamed {name} would break interpolation at runtime`,
|
||||
);
|
||||
assertVariantSuperset(catalog[key], enCatalog[key], locale, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('plural-shape rule: ru accepts its full Intl category set and rejects categories it never selects', () => {
|
||||
const enEntry = { one: 'Cleared {count} layer', other: 'Cleared {count} layers' };
|
||||
// ru selects one/few/many/other, so the full four-variant translation of
|
||||
// an en { one, other } twin is a legal superset (adds only valid ru
|
||||
// categories) — exactly what stage-B translators must produce…
|
||||
assert.doesNotThrow(() => assertVariantSuperset(
|
||||
{ one: '…', few: '…', many: '…', other: '…' },
|
||||
enEntry,
|
||||
test('checkCatalog reports missing, stale, placeholder and plural problems', () => {
|
||||
const fixture = new Map([
|
||||
['Saved {name}', { plural: false }],
|
||||
['{count} keys', { plural: true, one: '{count} key' }],
|
||||
['Untranslated', { plural: false }],
|
||||
]);
|
||||
const result = checkCatalog(
|
||||
'ru',
|
||||
'layers.clear.toast.cleared',
|
||||
));
|
||||
// …while a variant Intl never selects for ru (a typo like 'xother') fails.
|
||||
assert.throws(
|
||||
() => assertVariantSuperset({ one: '…', other: '…', xother: '…' }, enEntry, 'ru', 'demo.key'),
|
||||
/xother/,
|
||||
{
|
||||
'Saved {name}': 'Сохранено {nmae}',
|
||||
'{count} keys': {
|
||||
one: '{count} ключ',
|
||||
few: '{count} ключа',
|
||||
many: '{count} ключей',
|
||||
},
|
||||
Removed: 'Удалено',
|
||||
},
|
||||
fixture,
|
||||
);
|
||||
// Dropping a mandatory en variant fails even when all additions are valid.
|
||||
assert.throws(
|
||||
() => assertVariantSuperset({ few: '…', many: '…', other: '…' }, enEntry, 'ru', 'demo.key'),
|
||||
/every en variant/,
|
||||
);
|
||||
// String ↔ plural-object drift still fails exactly as before.
|
||||
assert.throws(
|
||||
() => assertVariantSuperset('plain string', enEntry, 'ru', 'demo.key'),
|
||||
/string ↔ plural-object/,
|
||||
);
|
||||
// The gate stays honest about what the runtime can select: validCategories
|
||||
// is read from Intl, not hardcoded per locale.
|
||||
assert.deepEqual([...validPluralCategories('en')].sort(), ['one', 'other']);
|
||||
assert.deepEqual([...validPluralCategories('ru')].sort(), ['few', 'many', 'one', 'other']);
|
||||
});
|
||||
|
||||
test('exact key parity for every shipped locale once translation is complete (parity flip)', () => {
|
||||
if (!REQUIRE_FULL_PARITY) {
|
||||
// Subset mode: every locale key resolves; en-only keys fall back silently.
|
||||
for (const [locale, keys] of localeKeys) {
|
||||
for (const key of keys) assert.ok(enKeys.includes(key), `${locale}:${key}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const [locale, keys] of localeKeys) {
|
||||
assert.deepEqual(keys, enKeys, `strict gate is on: ${locale} must be key-complete`);
|
||||
}
|
||||
});
|
||||
|
||||
test('mergeNamespace prefixes relative keys and rejects malformed namespaces', () => {
|
||||
const merged = mergeNamespace({ NAMESPACE: 'demo', default: { 'a.b': 'x', plain: 'y' } });
|
||||
assert.deepEqual({ ...merged }, { 'demo.a.b': 'x', 'demo.plain': 'y' });
|
||||
assert.throws(() => mergeNamespace({}), /must export a lowercase NAMESPACE/);
|
||||
assert.throws(() => mergeNamespace({ NAMESPACE: 'Nope' }), /must export a lowercase NAMESPACE/);
|
||||
assert.throws(() => mergeNamespace({ NAMESPACE: '' }), /must export a lowercase NAMESPACE/);
|
||||
assert.deepEqual({ ...mergeNamespace({ NAMESPACE: 'empty' }) }, {});
|
||||
});
|
||||
|
||||
test('registered namespaces produce the documented dot prefixes', () => {
|
||||
const prefixes = new Set(enKeys.map((key) => key.split('.')[0]));
|
||||
assert.deepEqual([...prefixes].sort(), ['cockpit', 'layers', 'setup', 'shell']);
|
||||
assert.deepEqual(result.missing, ['Untranslated']);
|
||||
assert.deepEqual(result.stale, ['Removed']);
|
||||
assert.equal(result.errors.length, 2);
|
||||
});
|
||||
+175
-427
@@ -1,457 +1,205 @@
|
||||
// i18n core behavior: locale resolution precedence (incl. ?lang=), guarded
|
||||
// storage, English fallback, interpolation, plural selection, document
|
||||
// application on all four attributes, and hash-exact reload handling.
|
||||
import { test } from 'node:test';
|
||||
import { test, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CATALOG_LOCALES,
|
||||
DEFAULT_LOCALE,
|
||||
LOCALE_METADATA,
|
||||
LOCALE_STORAGE_KEY,
|
||||
applyDocumentLanguage,
|
||||
availableLocales,
|
||||
normalizeLocale,
|
||||
resolveLocale,
|
||||
resolveLocalePair,
|
||||
writeStoredLocale,
|
||||
} from './locale.js';
|
||||
|
||||
import {
|
||||
applyDocumentTranslations,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
formatRelativeTime,
|
||||
getCatalog,
|
||||
getLocale,
|
||||
hasMessage,
|
||||
msg,
|
||||
persistLocaleAndReload,
|
||||
resolveMessage,
|
||||
selectPluralPattern,
|
||||
setLocale,
|
||||
t,
|
||||
tn,
|
||||
} from './index.js';
|
||||
import {
|
||||
LOCALE_STORAGE_KEY,
|
||||
applyDocumentLanguage,
|
||||
normalizeLocale,
|
||||
resolveLocale,
|
||||
writeStoredLocale,
|
||||
} from './locale.js';
|
||||
|
||||
// Tests mutate module locale state; each dependent test re-pins it.
|
||||
function pinLocale(locale) {
|
||||
assert.equal(setLocale(locale), locale);
|
||||
}
|
||||
afterEach(() => setLocale('en'));
|
||||
|
||||
test('normalizeLocale folds regional variants and rejects unsupported tags', () => {
|
||||
assert.equal(normalizeLocale('es'), 'es');
|
||||
assert.equal(normalizeLocale('ES'), 'es');
|
||||
assert.equal(normalizeLocale('es-MX'), 'es');
|
||||
assert.equal(normalizeLocale('es_419'), 'es');
|
||||
assert.equal(normalizeLocale('en'), 'en');
|
||||
assert.equal(normalizeLocale('en-GB'), 'en');
|
||||
assert.equal(normalizeLocale('fr'), 'fr');
|
||||
assert.equal(normalizeLocale('FR'), 'fr');
|
||||
assert.equal(normalizeLocale('fr-CA'), 'fr');
|
||||
assert.equal(normalizeLocale('fr_FR'), 'fr');
|
||||
assert.equal(normalizeLocale('ru'), 'ru');
|
||||
assert.equal(normalizeLocale('RU'), 'ru');
|
||||
assert.equal(normalizeLocale('ru-RU'), 'ru');
|
||||
assert.equal(normalizeLocale('ru_KZ'), 'ru');
|
||||
assert.equal(normalizeLocale('uk'), 'uk');
|
||||
assert.equal(normalizeLocale('UK'), 'uk', 'uppercase country spelling still folds to the language tag');
|
||||
assert.equal(normalizeLocale('uk-UA'), 'uk');
|
||||
assert.equal(normalizeLocale('uk_UA'), 'uk');
|
||||
assert.equal(normalizeLocale('de'), null, 'no shipped de catalog');
|
||||
assert.equal(normalizeLocale('english'), null);
|
||||
assert.equal(normalizeLocale(''), null);
|
||||
assert.equal(normalizeLocale(null), null);
|
||||
assert.equal(normalizeLocale(42), null);
|
||||
});
|
||||
|
||||
test('resolution precedence: ?lang= is one-shot, pair-scoped, and never persisted', () => {
|
||||
// English is the only shipped catalog in the foundation state, so the
|
||||
// offered pair is en-only: an override naming any other locale — a
|
||||
// planned-but-unshipped code ('es') or garbage ('de') — is ignored while
|
||||
// the rest of the chain keeps looking. The override-beats-everything pin
|
||||
// comes back with the first secondary-locale PR (docs/TRANSLATORS.md).
|
||||
const storage = { getItem: () => 'en', setItem() { throw new Error('must not be written'); } };
|
||||
assert.equal(resolveLocale({
|
||||
location: { search: '?lang=es' },
|
||||
storage,
|
||||
languages: ['en-US'],
|
||||
}), 'en');
|
||||
// A garbage override is ignored — the chain keeps looking.
|
||||
assert.equal(resolveLocale({
|
||||
location: { search: '?lang=de' },
|
||||
storage,
|
||||
languages: ['en-US'],
|
||||
}), 'en');
|
||||
// A locale code that normalizes but has no shipped catalog is equally
|
||||
// ignored: fr cannot be offered before its locale PR lands.
|
||||
assert.equal(resolveLocale({
|
||||
location: { search: '?lang=fr' },
|
||||
storage,
|
||||
languages: ['en-US'],
|
||||
}), 'en');
|
||||
});
|
||||
|
||||
test('resolution precedence: a stored preference is re-checked against the offered pair', () => {
|
||||
// 'es' may have been stored under a pair that once offered it; while
|
||||
// English is the only shipped catalog the stored value is out-of-pair and
|
||||
// reads as absent, so the navigator list decides — the stored preference
|
||||
// degrades gracefully instead of rendering English under an es <html lang>.
|
||||
assert.equal(resolveLocale({
|
||||
location: { search: '' },
|
||||
storage: { getItem: () => 'es' },
|
||||
languages: ['en-US', 'en'],
|
||||
}), 'en');
|
||||
});
|
||||
|
||||
test('resolution precedence: navigator languages resolve before the English default', () => {
|
||||
// Regional variants normalize to the primary tag and only then face the
|
||||
// pair check: 'en-GB' resolves through the offered pair, unshipped codes
|
||||
// ('fr-CA', 'es-MX') defer to the next step.
|
||||
assert.equal(resolveLocale({
|
||||
location: { search: '' },
|
||||
storage: { getItem: () => 'xx' },
|
||||
languages: ['en-GB'],
|
||||
}), 'en');
|
||||
assert.equal(resolveLocale({
|
||||
location: { search: '' },
|
||||
storage: { getItem: () => 'xx' },
|
||||
languages: ['fr-CA', 'es-MX'],
|
||||
}), 'en', 'no shipped fr/es catalog: neither navigator entry resolves');
|
||||
assert.equal(resolveLocale({ location: { search: '' }, storage: null, languages: ['fr-CA'] }), 'en');
|
||||
assert.equal(resolveLocale({ location: null, storage: null, languages: null }), DEFAULT_LOCALE);
|
||||
});
|
||||
|
||||
test('guarded storage: a throwing store (private mode / quota) never escapes', () => {
|
||||
const hostile = {
|
||||
getItem() { throw new Error('SecurityError'); },
|
||||
setItem() { throw new Error('SecurityError'); },
|
||||
};
|
||||
assert.equal(resolveLocale({ location: { search: '' }, storage: hostile, languages: ['es'] }), 'en',
|
||||
'an unshipped navigator locale defers to the en-only default');
|
||||
assert.equal(writeStoredLocale('es', hostile), false);
|
||||
assert.equal(writeStoredLocale('es', { setItem() { throw new Error('quota'); } }), false);
|
||||
assert.equal(writeStoredLocale('es', {}), false, 'no setItem function means no write');
|
||||
assert.equal(writeStoredLocale('es-AR', { setItem() {} }), true, 'stored value is normalized');
|
||||
});
|
||||
|
||||
test('applyDocumentLanguage reflects lang and dir from locale metadata', () => {
|
||||
const makeDoc = () => ({ documentElement: { lang: '', dir: '' } });
|
||||
const enDoc = makeDoc();
|
||||
assert.equal(applyDocumentLanguage(enDoc, 'en'), true);
|
||||
assert.deepEqual({ lang: enDoc.documentElement.lang, dir: enDoc.documentElement.dir },
|
||||
{ lang: 'en', dir: LOCALE_METADATA.en.dir });
|
||||
assert.equal(LOCALE_METADATA.en.dir, 'ltr', 'English is LTR by metadata');
|
||||
const esDoc = makeDoc();
|
||||
assert.equal(applyDocumentLanguage(esDoc, 'es-MX'), true);
|
||||
assert.deepEqual({ lang: esDoc.documentElement.lang, dir: esDoc.documentElement.dir },
|
||||
{ lang: 'es', dir: LOCALE_METADATA.es.dir });
|
||||
assert.equal(LOCALE_METADATA.es.dir, 'ltr', 'Spanish is LTR by metadata');
|
||||
const frDoc = makeDoc();
|
||||
assert.equal(applyDocumentLanguage(frDoc, 'fr-CA'), true);
|
||||
assert.deepEqual({ lang: frDoc.documentElement.lang, dir: frDoc.documentElement.dir },
|
||||
{ lang: 'fr', dir: LOCALE_METADATA.fr.dir });
|
||||
assert.equal(LOCALE_METADATA.fr.dir, 'ltr', 'French is LTR by metadata');
|
||||
const ruDoc = makeDoc();
|
||||
assert.equal(applyDocumentLanguage(ruDoc, 'ru-RU'), true);
|
||||
assert.deepEqual({ lang: ruDoc.documentElement.lang, dir: ruDoc.documentElement.dir },
|
||||
{ lang: 'ru', dir: LOCALE_METADATA.ru.dir });
|
||||
assert.equal(LOCALE_METADATA.ru.dir, 'ltr', 'Russian is LTR by metadata');
|
||||
const ukDoc = makeDoc();
|
||||
assert.equal(applyDocumentLanguage(ukDoc, 'uk_UA'), true);
|
||||
assert.deepEqual({ lang: ukDoc.documentElement.lang, dir: ukDoc.documentElement.dir },
|
||||
{ lang: 'uk', dir: LOCALE_METADATA.uk.dir });
|
||||
assert.equal(LOCALE_METADATA.uk.dir, 'ltr', 'Ukrainian is LTR by metadata');
|
||||
assert.equal(applyDocumentLanguage(null, 'es'), false);
|
||||
assert.equal(applyDocumentLanguage({ documentElement: null }, 'es'), false);
|
||||
// An unsupported locale still yields valid metadata, never an empty dir.
|
||||
const fallbackDoc = makeDoc();
|
||||
applyDocumentLanguage(fallbackDoc, 'de');
|
||||
assert.equal(fallbackDoc.documentElement.lang, DEFAULT_LOCALE);
|
||||
assert.equal(fallbackDoc.documentElement.dir, 'ltr');
|
||||
});
|
||||
|
||||
test('t() resolves seed keys, interpolates named placeholders, and keeps unknown ones literal', () => {
|
||||
pinLocale('en');
|
||||
assert.equal(t('shell.title.subtitle'), 'NO PLACE LEFT BEHIND');
|
||||
assert.equal(
|
||||
t('shell.loading.status.tilesUnavailable', { detail: '403 from ion' }),
|
||||
'Google 3D Tiles unavailable (403 from ion). Loading the keyless globe...',
|
||||
);
|
||||
assert.equal(
|
||||
t('shell.loading.status.tilesUnavailable', {}),
|
||||
'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...',
|
||||
'an unprovided name stays visible instead of silently vanishing',
|
||||
);
|
||||
});
|
||||
|
||||
test('t() selects one/other plural variants through Intl.PluralRules', () => {
|
||||
pinLocale('en');
|
||||
assert.equal(t('layers.clear.toast.cleared', { count: 1 }), 'Cleared 1 data layer');
|
||||
assert.equal(t('layers.clear.toast.cleared', { count: 3 }), 'Cleared 3 data layers');
|
||||
assert.equal(t('layers.clear.toast.notCleared', { count: 1 }),
|
||||
'1 data layer could not be cleared');
|
||||
// Non-en plural-category selection is pinned per locale in that locale's
|
||||
// PR (es: one/other; ru/uk: one/few/many/other) — with real catalog values,
|
||||
// not synthetic ones. The selection CONTRACT itself stays pinned below
|
||||
// through the synthetic missing-category fallback under 'ru'.
|
||||
});
|
||||
|
||||
test('a missing key returns the key itself and never warns outside dev builds', () => {
|
||||
pinLocale('en');
|
||||
const warnings = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
try {
|
||||
assert.equal(t('shell.definitely.not.here'), 'shell.definitely.not.here');
|
||||
assert.equal(hasMessage('shell.definitely.not.here'), false);
|
||||
assert.equal(hasMessage('shell.title.subtitle'), true);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
// import.meta.env is absent under node:test, so the DEV gate keeps the
|
||||
// console quiet here; the warn path itself only runs on the dev server.
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test('a key missing in es resolves through the English fallback (pure lookup)', () => {
|
||||
// The shipped es seed mirrors en key-for-key, so the fallback path is
|
||||
// exercised through the pure resolver with synthetic catalogs — exactly the
|
||||
// shape stage-3 leaves behind while es translation lags.
|
||||
const catalogs = {
|
||||
es: Object.freeze({}),
|
||||
en: Object.freeze({ 'a.b': 'EN ONLY' }),
|
||||
};
|
||||
assert.equal(resolveMessage(catalogs, 'es', 'a.b'), 'EN ONLY');
|
||||
assert.equal(resolveMessage(catalogs, 'fr', 'a.b'), 'EN ONLY', 'unsupported locale reads en');
|
||||
assert.equal(resolveMessage(catalogs, 'es', 'nope'), undefined);
|
||||
assert.equal(resolveMessage(null, 'es', 'a.b'), undefined, 'no catalogs means no message');
|
||||
});
|
||||
|
||||
test('Intl formatters follow the active locale', () => {
|
||||
pinLocale('en');
|
||||
// Five significant digits: Spanish (CLDR minimumGroupingDigits=2) only
|
||||
// groups from five digits up, so a 4-digit value would hide the difference.
|
||||
assert.equal(formatNumber(12345.6), '12,345.6');
|
||||
assert.equal(formatDate(new Date(Date.UTC(2026, 0, 15)), { year: 'numeric', month: 'long', day: 'numeric' }),
|
||||
'January 15, 2026');
|
||||
assert.equal(formatRelativeTime(-5, 'minute'), '5 minutes ago');
|
||||
pinLocale('es');
|
||||
assert.equal(formatNumber(12345.6), '12.345,6');
|
||||
assert.equal(formatDate(new Date(Date.UTC(2026, 0, 15)), { year: 'numeric', month: 'long', day: 'numeric' }),
|
||||
'15 de enero de 2026');
|
||||
assert.equal(formatRelativeTime(-5, 'minute'), 'hace 5 minutos');
|
||||
// Formatter cache is keyed per locale: switching back must not leak es forms.
|
||||
pinLocale('en');
|
||||
assert.equal(formatNumber(12345.6), '12,345.6');
|
||||
});
|
||||
|
||||
test('persistLocaleAndReload stores the choice, keeps the hash exact, strips ?lang', () => {
|
||||
const writes = [];
|
||||
const navigations = [];
|
||||
const storage = { setItem(key, value) { writes.push([key, value]); } };
|
||||
const location = {
|
||||
href: 'http://localhost:4173/?lang=es&welcome=1#cam=30.2672,-97.7431,alt=2500',
|
||||
assign(url) { navigations.push(url); },
|
||||
};
|
||||
assert.equal(persistLocaleAndReload('es-MX', { location, storage }), true);
|
||||
assert.deepEqual(writes, [[LOCALE_STORAGE_KEY, 'es']], 'normalized value under the versioned key');
|
||||
assert.equal(navigations.length, 1);
|
||||
assert.equal(navigations[0], 'http://localhost:4173/?welcome=1#cam=30.2672,-97.7431,alt=2500');
|
||||
|
||||
// No ?lang present: the URL round-trips unchanged, hash included.
|
||||
navigations.length = 0;
|
||||
persistLocaleAndReload('en', { location: { href: 'http://localhost:4173/#scene=coast', assign: (u) => navigations.push(u) } });
|
||||
assert.deepEqual(navigations, ['http://localhost:4173/#scene=coast']);
|
||||
|
||||
// A blocked store must not block the reload.
|
||||
const blocked = { setItem() { throw new Error('SecurityError'); } };
|
||||
navigations.length = 0;
|
||||
assert.equal(persistLocaleAndReload('es', { location: { href: 'http://x/?lang=es#a', assign: (u) => navigations.push(u) }, storage: blocked }), true);
|
||||
assert.deepEqual(navigations, ['http://x/#a']);
|
||||
|
||||
// Nothing navigable injected: no throw, no reload claim.
|
||||
assert.equal(persistLocaleAndReload('es', { location: null }), false);
|
||||
assert.equal(persistLocaleAndReload('es', { location: {} }), false);
|
||||
|
||||
// Real-browser shape: a reloadable location MUST force the reload — assign()
|
||||
// to a byte-identical URL never navigates, which stranded stored-locale
|
||||
// switches (no ?lang present, hash unchanged).
|
||||
const reloaded = [];
|
||||
const assigned = [];
|
||||
const replaced = [];
|
||||
assert.equal(persistLocaleAndReload('es', {
|
||||
location: {
|
||||
href: 'http://localhost:4173/#scene=coast',
|
||||
assign: (u) => assigned.push(u),
|
||||
reload: () => reloaded.push(true),
|
||||
},
|
||||
history: { replaceState: (_s, _t, u) => replaced.push(u) },
|
||||
}), true);
|
||||
assert.deepEqual(replaced, ['http://localhost:4173/#scene=coast'], '?lang-free URL passes through replaceState');
|
||||
assert.equal(reloaded.length, 1, 'reload is forced');
|
||||
assert.equal(assigned.length, 0, 'assign is not used when reload exists');
|
||||
});
|
||||
|
||||
test('a plural entry lacking a category the active locale selects degrades to other (pure selection)', () => {
|
||||
// Graceful degradation stays pinned after the ru/uk translation landed:
|
||||
// full-parity catalogs can no longer carry a partial entry, so the
|
||||
// runtime contract — entry[category] ?? entry.other in selectPluralPattern
|
||||
// (src/i18n/index.js) — is exercised with a synthetic { one, other } twin
|
||||
// under ru, which selects few/many for 2–4/5–20. This is the runtime half
|
||||
// of the plural-superset gate in catalog.test.mjs.
|
||||
const twin = Object.freeze({ one: 'ONE {count}', other: 'OTHER {count}' });
|
||||
pinLocale('ru');
|
||||
assert.equal(selectPluralPattern(twin, { count: 2 }, 'demo.key'), 'OTHER {count}',
|
||||
"ru selects 'few' for 2 and the entry does not carry it: entry.other renders");
|
||||
assert.equal(selectPluralPattern(twin, { count: 5 }, 'demo.key'), 'OTHER {count}',
|
||||
"ru selects 'many' for 5 and the entry does not carry it: entry.other renders");
|
||||
assert.equal(selectPluralPattern(twin, { count: 21 }, 'demo.key'), 'ONE {count}',
|
||||
"21 selects 'one', which the entry carries directly");
|
||||
assert.equal(selectPluralPattern(twin, { count: 1.5 }, 'demo.key'), 'OTHER {count}',
|
||||
'fractional counts select other');
|
||||
assert.equal(selectPluralPattern(twin, {}, 'demo.key'), 'OTHER {count}',
|
||||
'a non-finite count degrades to entry.other ?? entry.one');
|
||||
pinLocale('en');
|
||||
});
|
||||
|
||||
class FakeElement {
|
||||
constructor(attributes) {
|
||||
this.attributes = new Map(Object.entries(attributes));
|
||||
this.textContent = '';
|
||||
}
|
||||
getAttribute(name) { return this.attributes.has(name) ? this.attributes.get(name) : null; }
|
||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||
}
|
||||
|
||||
function fakeDocumentFor(elements) {
|
||||
function memoryStorage(initial = {}) {
|
||||
const data = new Map(Object.entries(initial));
|
||||
return {
|
||||
querySelectorAll(selector) {
|
||||
const match = /^\[([a-z0-9-]+)\]$/.exec(selector);
|
||||
if (!match) return [];
|
||||
return elements.filter((element) => element.getAttribute(match[1]) !== null);
|
||||
},
|
||||
getItem: (key) => (data.has(key) ? data.get(key) : null),
|
||||
setItem: (key, value) => data.set(key, String(value)),
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
test('applyDocumentTranslations writes all four attributes and skips unknown keys', () => {
|
||||
pinLocale('en');
|
||||
const elements = [
|
||||
new FakeElement({ 'data-i18n': 'shell.title.subtitle' }),
|
||||
new FakeElement({ 'data-i18n-title': 'shell.actions.clearLayers.title' }),
|
||||
new FakeElement({ 'data-i18n-aria-label': 'shell.actions.share.ariaLabel' }),
|
||||
new FakeElement({ 'data-i18n-placeholder': 'setup.keySetup.title' }),
|
||||
new FakeElement({ 'data-i18n': 'shell.missing.key' }),
|
||||
new FakeElement({ 'data-i18n': '' }),
|
||||
];
|
||||
const applied = applyDocumentTranslations(fakeDocumentFor(elements));
|
||||
assert.equal(applied, 4);
|
||||
assert.equal(elements[0].textContent, 'NO PLACE LEFT BEHIND');
|
||||
assert.equal(elements[1].getAttribute('title'), 'Turn off all selected data layers');
|
||||
assert.equal(elements[2].getAttribute('aria-label'), 'Copy share link');
|
||||
assert.equal(elements[3].getAttribute('placeholder'), 'Power up the globe');
|
||||
// A key that resolves nowhere leaves the element untouched.
|
||||
assert.equal(elements[4].textContent, '');
|
||||
assert.equal(elements[5].textContent, '');
|
||||
assert.equal(applyDocumentTranslations(null), 0);
|
||||
assert.equal(applyDocumentTranslations({}), 0);
|
||||
test('normalizeLocale folds regional variants and rejects unshipped tags', () => {
|
||||
assert.equal(normalizeLocale('ru-RU'), 'ru');
|
||||
assert.equal(normalizeLocale(' RU '), 'ru');
|
||||
assert.equal(normalizeLocale('en_GB'), 'en');
|
||||
assert.equal(normalizeLocale('xx'), null);
|
||||
assert.equal(normalizeLocale(undefined), null);
|
||||
});
|
||||
|
||||
test('getCatalog exposes the merged, dot-prefixed registry for every shipped locale', () => {
|
||||
assert.deepEqual([...CATALOG_LOCALES], ['en'],
|
||||
'the foundation ships English only; each locale PR appends its code here');
|
||||
for (const locale of CATALOG_LOCALES) {
|
||||
const catalog = getCatalog(locale);
|
||||
assert.ok(catalog, `catalog for ${locale}`);
|
||||
assert.ok(Object.keys(catalog).length > 0);
|
||||
for (const key of Object.keys(catalog)) {
|
||||
assert.match(key, /^(shell|cockpit|layers|setup)\./, `${locale} key ${key}`);
|
||||
}
|
||||
}
|
||||
// Codes without a shipped catalog stay null even though they normalize
|
||||
// (locale-code hygiene): shipping is catalog-driven. An unknown locale
|
||||
// stays null too.
|
||||
assert.equal(getCatalog('es'), null);
|
||||
assert.equal(getCatalog('fr'), null);
|
||||
assert.equal(getCatalog('ru'), null);
|
||||
assert.equal(getCatalog('uk'), null);
|
||||
assert.equal(getCatalog('de'), null);
|
||||
assert.equal(getLocale(), 'en', 'state survived the whole file');
|
||||
});
|
||||
|
||||
test('locale pair: built-in and configured pairs degrade to en-only while only English ships', () => {
|
||||
// English is the only shipped catalog, so the offered set is ['en'] under
|
||||
// any configuration — a pair naming anything else names an unshipped
|
||||
// locale. The dedup-ordering pin (default, secondary, then en as the
|
||||
// always-shipped fallback) returns with the first secondary-locale PR.
|
||||
assert.deepEqual([...availableLocales()], ['en'], 'no config = built-in en-only pair');
|
||||
assert.deepEqual([...availableLocales({ defaultLocale: 'fr', secondaryLocale: 'en' })], ['en'],
|
||||
'fr has no shipped catalog: the pair degenerates');
|
||||
// Regional spellings still normalize before validation.
|
||||
assert.deepEqual([...availableLocales({ defaultLocale: 'FR-fr', secondaryLocale: 'EN' })], ['en']);
|
||||
// A blank field means "unset" (empty .env line) and takes its built-in default.
|
||||
assert.deepEqual([...availableLocales({ defaultLocale: ' ' })], ['en']);
|
||||
const pair = resolveLocalePair();
|
||||
assert.equal(pair.defaultLocale, 'en');
|
||||
assert.equal(pair.secondaryLocale, 'en');
|
||||
assert.ok(Object.isFrozen(pair) && Object.isFrozen(pair.availableLocales));
|
||||
});
|
||||
|
||||
test('locale pair: a configured pair naming an unshipped locale degrades to English resolution', () => {
|
||||
const config = { defaultLocale: 'fr', secondaryLocale: 'en' };
|
||||
// fr normalizes but has no shipped catalog: the pair is unusable, the
|
||||
// built-in en-only pair applies, and every resolution step yields English.
|
||||
assert.equal(resolveLocale({ location: { search: '' }, storage: null, languages: null, config }), 'en');
|
||||
assert.equal(resolveLocale({ location: { search: '?lang=fr' }, storage: { getItem: () => 'fr' }, languages: null, config }), 'en');
|
||||
assert.equal(resolveLocale({ location: { search: '' }, storage: { getItem: () => 'fr' }, languages: ['fr-FR'], config }), 'en');
|
||||
assert.equal(resolveLocale({ location: { search: '?lang=en' }, storage: { getItem: () => 'en' }, languages: null, config }), 'en');
|
||||
});
|
||||
|
||||
test('locale pair: ?lang= cannot offer a locale without a shipped catalog', () => {
|
||||
test('resolveLocale: ?lang= wins, then the stored choice, then browser languages, then English', () => {
|
||||
const storage = memoryStorage({ [LOCALE_STORAGE_KEY]: 'ru' });
|
||||
assert.equal(
|
||||
resolveLocale({ location: { search: '?lang=fr' }, storage: { getItem: () => 'en' }, languages: null }),
|
||||
resolveLocale({
|
||||
location: { search: '?lang=en' },
|
||||
storage,
|
||||
languages: ['ru'],
|
||||
}),
|
||||
'en',
|
||||
'fr has no catalog: the override falls through to the stored preference',
|
||||
);
|
||||
assert.equal(
|
||||
resolveLocale({ location: { search: '' }, storage, languages: ['en'] }),
|
||||
'ru',
|
||||
);
|
||||
assert.equal(
|
||||
resolveLocale({
|
||||
location: { search: '?lang=es' },
|
||||
storage: { getItem: () => 'en' },
|
||||
languages: null,
|
||||
config: { defaultLocale: 'es', secondaryLocale: 'fr' },
|
||||
location: { search: '?lang=xx' },
|
||||
storage: memoryStorage(),
|
||||
languages: ['de-DE', 'ru-RU'],
|
||||
}),
|
||||
'ru',
|
||||
);
|
||||
assert.equal(
|
||||
resolveLocale({
|
||||
location: { search: '' },
|
||||
storage: memoryStorage(),
|
||||
languages: ['de'],
|
||||
}),
|
||||
'en',
|
||||
'a configured pair cannot smuggle an unshipped locale into the offered set',
|
||||
);
|
||||
});
|
||||
|
||||
test('locale pair: a stored preference outside the offered pair falls through', () => {
|
||||
// A stored 'fr'/'es' reads as absent while those locales are unshipped;
|
||||
// resolution defers to the navigator list and finally the built-in default.
|
||||
assert.equal(resolveLocale({ location: { search: '' }, storage: { getItem: () => 'fr' }, languages: ['en-US'] }), 'en');
|
||||
assert.equal(resolveLocale({ location: { search: '' }, storage: { getItem: () => 'es' }, languages: null }), 'en',
|
||||
'…down to the default');
|
||||
test('a throwing storage never escapes', () => {
|
||||
const storage = {
|
||||
getItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
setItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
resolveLocale({ location: { search: '' }, storage, languages: [] }),
|
||||
'en',
|
||||
);
|
||||
assert.equal(writeStoredLocale('ru', storage), false);
|
||||
});
|
||||
|
||||
test('locale pair: invalid, unshipped, or degenerate values fall back to the built-in en-only pair (dev-warn only)', () => {
|
||||
const warnings = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
try {
|
||||
const badPairs = [
|
||||
{ defaultLocale: 'de', secondaryLocale: 'es' }, // unknown locale code
|
||||
{ defaultLocale: 'fr', secondaryLocale: 'fr' }, // unshipped + degenerate: same locale twice
|
||||
{ defaultLocale: 'en', secondaryLocale: 'en' }, // equals the built-in pair: silent, still en-only
|
||||
{ defaultLocale: 'english', secondaryLocale: 'ES' },
|
||||
{ defaultLocale: 'en', secondaryLocale: 'es' }, // es is a known code without a shipped catalog
|
||||
];
|
||||
for (const bad of badPairs) {
|
||||
const pair = resolveLocalePair(bad);
|
||||
assert.equal(pair.defaultLocale, 'en', JSON.stringify(bad));
|
||||
assert.equal(pair.secondaryLocale, 'en', JSON.stringify(bad));
|
||||
assert.deepEqual([...pair.availableLocales], ['en'], JSON.stringify(bad));
|
||||
assert.equal(resolveLocale({ location: { search: '' }, storage: null, languages: null, config: bad }), 'en');
|
||||
}
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
// import.meta.env is absent under node:test, so the DEV gate keeps the
|
||||
// console quiet here; the warn fires only on the dev server.
|
||||
assert.deepEqual(warnings, []);
|
||||
test('applyDocumentLanguage sets lang and dir', () => {
|
||||
const doc = { documentElement: {} };
|
||||
applyDocumentLanguage(doc, 'ru-RU');
|
||||
assert.deepEqual(doc.documentElement, { lang: 'ru', dir: 'ltr' });
|
||||
});
|
||||
|
||||
test('t() returns English text in English and translates in Russian', () => {
|
||||
assert.equal(t('POWERED UP'), 'POWERED UP');
|
||||
setLocale('ru');
|
||||
assert.notEqual(t('POWERED UP'), 'POWERED UP');
|
||||
});
|
||||
|
||||
test('t() interpolates named values and keeps unknown names literal', () => {
|
||||
assert.equal(
|
||||
t('Save failed: {detail}', { detail: 'disk full' }),
|
||||
'Save failed: disk full',
|
||||
);
|
||||
assert.equal(
|
||||
t('Hello {who} {missing}', { who: 'pilot' }),
|
||||
'Hello pilot {missing}',
|
||||
);
|
||||
});
|
||||
|
||||
test('t() falls back to English for text without a translation', () => {
|
||||
setLocale('ru');
|
||||
assert.equal(t('No such message {n}', { n: 1 }), 'No such message 1');
|
||||
});
|
||||
|
||||
test('tn() picks English and Russian plural forms', () => {
|
||||
const label = (count) =>
|
||||
tn(
|
||||
count,
|
||||
'POWER UP · {count} KEY WAITING',
|
||||
'POWER UP · {count} KEYS WAITING',
|
||||
);
|
||||
assert.equal(label(1), 'POWER UP · 1 KEY WAITING');
|
||||
assert.equal(label(3), 'POWER UP · 3 KEYS WAITING');
|
||||
setLocale('ru');
|
||||
assert.match(label(1), / 1 КЛЮЧ(?![А-ЯЁ])/);
|
||||
assert.match(label(3), / 3 КЛЮЧА(?![А-ЯЁ])/);
|
||||
assert.match(label(5), / 5 КЛЮЧЕЙ(?![А-ЯЁ])/);
|
||||
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/);
|
||||
});
|
||||
|
||||
test('msg() returns its text unchanged', () => {
|
||||
assert.equal(msg('Live Flights'), 'Live Flights');
|
||||
});
|
||||
|
||||
test('Intl formatters follow the active locale', () => {
|
||||
const date = new Date(Date.UTC(2026, 0, 15));
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
};
|
||||
assert.equal(formatDate(date, options), 'January 15, 2026');
|
||||
assert.equal(formatNumber(1234.5), '1,234.5');
|
||||
setLocale('ru');
|
||||
assert.equal(formatDate(date, options), '15 января 2026 г.');
|
||||
assert.equal(formatNumber(1234.5), '1 234,5');
|
||||
});
|
||||
|
||||
function fakeElement({ text = '', attributes = {} } = {}) {
|
||||
const attrs = new Map(Object.entries(attributes));
|
||||
return {
|
||||
textContent: text,
|
||||
getAttribute: (name) => (attrs.has(name) ? attrs.get(name) : null),
|
||||
setAttribute: (name, value) => attrs.set(name, value),
|
||||
attrs,
|
||||
};
|
||||
}
|
||||
|
||||
test('applyDocumentTranslations translates opted-in text and attributes in place', () => {
|
||||
const label = fakeElement({ text: '\n POWERED\n UP ' });
|
||||
const pin = fakeElement({ attributes: { title: 'POWERED UP' } });
|
||||
const unknown = fakeElement({ text: 'No such message' });
|
||||
const doc = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '[data-i18n]') return [label, unknown];
|
||||
if (selector === '[data-i18n-title]') return [pin];
|
||||
return [];
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
applyDocumentTranslations(doc),
|
||||
0,
|
||||
'English leaves markup alone',
|
||||
);
|
||||
setLocale('ru');
|
||||
assert.equal(applyDocumentTranslations(doc), 2);
|
||||
assert.equal(label.textContent, t('POWERED UP'));
|
||||
assert.equal(pin.attrs.get('title'), t('POWERED UP'));
|
||||
assert.equal(unknown.textContent, 'No such message');
|
||||
});
|
||||
|
||||
test('persistLocaleAndReload stores the choice, keeps the hash, strips ?lang', () => {
|
||||
const storage = memoryStorage();
|
||||
const calls = [];
|
||||
const location = {
|
||||
href: 'https://gev.local/?lang=en&x=1#scene=a',
|
||||
assign: (url) => calls.push(['assign', url]),
|
||||
reload: () => calls.push(['reload']),
|
||||
};
|
||||
const history = {
|
||||
replaceState: (_s, _t, url) => calls.push(['replace', url]),
|
||||
};
|
||||
assert.equal(
|
||||
persistLocaleAndReload('ru', { location, history, storage }),
|
||||
true,
|
||||
);
|
||||
assert.equal(storage.data.get(LOCALE_STORAGE_KEY), 'ru');
|
||||
assert.deepEqual(calls, [
|
||||
['replace', 'https://gev.local/?x=1#scene=a'],
|
||||
['reload'],
|
||||
]);
|
||||
});
|
||||
+123
-225
@@ -1,93 +1,32 @@
|
||||
// God's Eye View i18n core (phase 1): catalog registry, translation, and
|
||||
// Intl-based formatting. English is the source/fallback catalog; a missing key
|
||||
// in another locale falls back to English with a development-only warning.
|
||||
// English is also the ONLY shipped catalog in this foundation change — each
|
||||
// additional locale lands as a stacked follow-up PR following the recipe in
|
||||
// docs/TRANSLATORS.md (catalog files + registration + CATALOG_LOCALES entry).
|
||||
// Every shipped catalog is built UNCONDITIONALLY — the build cost is trivial
|
||||
// and the registry stays declarative — but only locales in the configured pair
|
||||
// are offered/accepted (see locale.js resolveLocalePair).
|
||||
// God's Eye View i18n: the English source text is the message key.
|
||||
//
|
||||
// APPEND-ONLY NAMESPACE REGISTRATION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// A worker adding a namespace creates locales/<locale>/<ns>.js (exporting
|
||||
// NAMESPACE plus a default message map of namespace-relative keys), then
|
||||
// appends ONE import line per locale and pushes the module into that locale's
|
||||
// array below. Nothing else changes, so concurrent workers merge trivially.
|
||||
// Namespace-relative keys are prefixed at registration time: shell.*,
|
||||
// cockpit.*, layers.*, setup.*.
|
||||
// t('Save keys') → 'Сохранить ключи'
|
||||
// t('Cleared {name}', { name }) → named interpolation
|
||||
// tn(count, '{count} camera', '{count} cameras') → plural by Intl.PluralRules
|
||||
// msg('Live Flights') → marks a string kept in data
|
||||
// for a later t(); returns it as is
|
||||
//
|
||||
// English needs no catalog. Every other locale is one flat JSON file,
|
||||
// locales/<code>.json, keyed by the English text (for tn, by the English
|
||||
// plural form). A missing entry falls back to English.
|
||||
// `npm run i18n:check` keeps the catalogs in step with the code.
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
normalizeLocale,
|
||||
writeStoredLocale,
|
||||
} from './locale.js';
|
||||
import ru from './locales/ru.json' with { type: 'json' };
|
||||
|
||||
import * as enShell from './locales/en/shell.js';
|
||||
import * as enCockpit from './locales/en/cockpit.js';
|
||||
import * as enLayers from './locales/en/layers.js';
|
||||
import * as enSetup from './locales/en/setup.js';
|
||||
|
||||
// Append new namespace modules here (one import + one entry per locale).
|
||||
const EN_NAMESPACES = [enShell, enCockpit, enLayers, enSetup];
|
||||
|
||||
/**
|
||||
* Prefix one namespace module's flat, namespace-relative keys with its
|
||||
* declared namespace: { 'title.subtitle': … } + NAMESPACE 'shell' becomes
|
||||
* 'shell.title.subtitle'.
|
||||
* @param {{NAMESPACE: string, default?: object}} namespaceModule
|
||||
* @returns {Record<string, string|object>} Frozen prefixed message map.
|
||||
*/
|
||||
export function mergeNamespace(namespaceModule) {
|
||||
const prefix =
|
||||
typeof namespaceModule?.NAMESPACE === 'string'
|
||||
? namespaceModule.NAMESPACE.trim()
|
||||
: '';
|
||||
if (!/^[a-z][a-z0-9-]*$/.test(prefix)) {
|
||||
throw new Error(
|
||||
`i18n namespace module must export a lowercase NAMESPACE, got ${JSON.stringify(prefix)}`,
|
||||
);
|
||||
}
|
||||
const merged = {};
|
||||
for (const [key, value] of Object.entries(namespaceModule.default || {})) {
|
||||
merged[`${prefix}.${key}`] = value;
|
||||
}
|
||||
return Object.freeze(merged);
|
||||
}
|
||||
|
||||
/** Merge namespaces into one catalog, rejecting duplicate dot-prefixed keys. */
|
||||
function buildCatalog(namespaceModules) {
|
||||
const catalog = {};
|
||||
for (const namespaceModule of namespaceModules) {
|
||||
for (const [key, value] of Object.entries(
|
||||
mergeNamespace(namespaceModule),
|
||||
)) {
|
||||
if (Object.hasOwn(catalog, key)) {
|
||||
throw new Error(`i18n duplicate catalog key: ${key}`);
|
||||
}
|
||||
catalog[key] = value;
|
||||
}
|
||||
}
|
||||
return Object.freeze(catalog);
|
||||
}
|
||||
|
||||
const CATALOGS = Object.freeze({
|
||||
[DEFAULT_LOCALE]: buildCatalog(EN_NAMESPACES),
|
||||
});
|
||||
|
||||
/**
|
||||
* The merged catalog for a locale (frozen; for coverage/parity tests and
|
||||
* tooling — runtime lookups go through t()).
|
||||
* @param {string} locale
|
||||
* @returns {Record<string, string|object>|null}
|
||||
*/
|
||||
export function getCatalog(locale) {
|
||||
const normalized = normalizeLocale(locale);
|
||||
return (normalized && CATALOGS[normalized]) || null;
|
||||
}
|
||||
const CATALOGS = Object.freeze({ ru });
|
||||
|
||||
let currentLocale = DEFAULT_LOCALE;
|
||||
|
||||
/** Active locale (any catalog locale; pair-scoped by resolveLocale at boot). */
|
||||
/** The catalog for a locale; null for English and unknown codes. */
|
||||
export function getCatalog(locale) {
|
||||
return CATALOGS[normalizeLocale(locale)] ?? null;
|
||||
}
|
||||
|
||||
/** Active locale. */
|
||||
export function getLocale() {
|
||||
return currentLocale;
|
||||
}
|
||||
@@ -106,44 +45,74 @@ export function setLocale(locale) {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
/** Development-only missing-key warning. Off under node:test and prod builds. */
|
||||
function warnMissingKey(key, detail) {
|
||||
// Vite replaces import.meta.env at build time; under node:test it is absent,
|
||||
// so warnings never fire in CI — only on the dev server.
|
||||
/** Development-only warning; silent under node:test and in production. */
|
||||
function warnMissing(text) {
|
||||
if (import.meta.env?.DEV !== true) return;
|
||||
console.warn(`[i18n] missing key "${key}"${detail ? ` (${detail})` : ''}`);
|
||||
console.warn(
|
||||
`[i18n] no ${currentLocale} translation for ${JSON.stringify(text)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g;
|
||||
|
||||
/** Named {placeholder} interpolation. Unknown names stay literal so a gap
|
||||
* stays visible instead of silently vanishing. */
|
||||
function interpolate(pattern, values) {
|
||||
if (!values) return pattern;
|
||||
return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) =>
|
||||
Object.hasOwn(values, name) ? String(values[name]) : literal,
|
||||
);
|
||||
}
|
||||
|
||||
function lookup(text) {
|
||||
const catalog = CATALOGS[currentLocale];
|
||||
if (!catalog) return undefined;
|
||||
if (Object.hasOwn(catalog, text)) return catalog[text];
|
||||
warnMissing(text);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure lookup shared by t() and the parity tests: the requested locale first,
|
||||
* then the English fallback catalog.
|
||||
* @param {Record<string, object>} catalogs Catalog map to read from.
|
||||
* @param {string} locale Locale to resolve within that map.
|
||||
* @param {string} key Dot-prefixed message key.
|
||||
* @returns {string|object|undefined} Raw entry, or undefined when nowhere.
|
||||
* Translate English source text into the active locale.
|
||||
* @param {string} text English text, e.g. 'Hold Space to talk'
|
||||
* @param {object} [values] Named interpolation values
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveMessage(catalogs, locale, key) {
|
||||
const source = catalogs || CATALOGS;
|
||||
const normalized = normalizeLocale(locale);
|
||||
if (
|
||||
normalized &&
|
||||
source[normalized] &&
|
||||
Object.hasOwn(source[normalized], key)
|
||||
) {
|
||||
return source[normalized][key];
|
||||
}
|
||||
const fallback = source[DEFAULT_LOCALE];
|
||||
return fallback && Object.hasOwn(fallback, key) ? fallback[key] : undefined;
|
||||
export function t(text, values) {
|
||||
const entry = lookup(text);
|
||||
return interpolate(typeof entry === 'string' ? entry : text, values);
|
||||
}
|
||||
|
||||
/** Whether the key resolves in the active locale or the English fallback. */
|
||||
export function hasMessage(key) {
|
||||
return resolveMessage(CATALOGS, currentLocale, key) !== undefined;
|
||||
/**
|
||||
* Translate a count-dependent message. The catalog entry for `other` holds
|
||||
* one form per Intl.PluralRules category of the locale ({ one, few, many, other }
|
||||
* for Russian). `{count}` is always available to the pattern.
|
||||
* @param {number} count
|
||||
* @param {string} one English singular, e.g. '{count} camera'
|
||||
* @param {string} other English plural, e.g. '{count} cameras'
|
||||
* @param {object} [values] Extra interpolation values
|
||||
* @returns {string}
|
||||
*/
|
||||
export function tn(count, one, other, values) {
|
||||
const entry = lookup(other);
|
||||
const category = pluralRules(currentLocale).select(count);
|
||||
let pattern;
|
||||
if (entry && typeof entry === 'object') {
|
||||
pattern = entry[category] ?? entry.other ?? other;
|
||||
} else {
|
||||
pattern = pluralRules(DEFAULT_LOCALE).select(count) === 'one' ? one : other;
|
||||
}
|
||||
return interpolate(pattern, { ...values, count });
|
||||
}
|
||||
|
||||
/** Mark English text that is stored in data and translated later by t(). */
|
||||
export function msg(text) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const pluralRulesCache = new Map();
|
||||
|
||||
function pluralRulesFor(locale) {
|
||||
function pluralRules(locale) {
|
||||
let rules = pluralRulesCache.get(locale);
|
||||
if (!rules) {
|
||||
rules = new Intl.PluralRules(locale);
|
||||
@@ -152,60 +121,10 @@ function pluralRulesFor(locale) {
|
||||
return rules;
|
||||
}
|
||||
|
||||
/** Select a plural variant via Intl.PluralRules for the ACTIVE locale;
|
||||
* an entry lacking the selected category degrades to entry.other. Exported
|
||||
* for tests: full-parity catalogs can no longer stage a partial entry, so
|
||||
* the fallback is pinned synthetically (i18n.test.mjs). */
|
||||
export function selectPluralPattern(entry, values, key) {
|
||||
const count = values?.count;
|
||||
if (typeof count !== 'number' || !Number.isFinite(count)) {
|
||||
warnMissingKey(key, 'plural entry requires a finite {count} value');
|
||||
return entry.other ?? entry.one;
|
||||
}
|
||||
const category = pluralRulesFor(currentLocale).select(count);
|
||||
return entry[category] ?? entry.other;
|
||||
}
|
||||
|
||||
const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g;
|
||||
|
||||
/** Named {placeholder} interpolation. Unprovided names stay literal so a gap
|
||||
* stays visible during development instead of silently vanishing. */
|
||||
function interpolate(pattern, values) {
|
||||
if (!values || typeof pattern !== 'string') return pattern;
|
||||
return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) =>
|
||||
Object.hasOwn(values, name) ? String(values[name]) : literal,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate one dot-prefixed key in the active locale. Missing keys in a
|
||||
* non-English locale fall back to English; keys missing everywhere return the
|
||||
* key itself. Plural entries ({ one, other }) require a numeric `count` value.
|
||||
* @param {string} key e.g. 'layers.clear.toast.cleared'
|
||||
* @param {object} [values] Named interpolation values, e.g. { count: 3, detail: '…' }
|
||||
* @returns {string}
|
||||
*/
|
||||
export function t(key, values) {
|
||||
const entry = resolveMessage(CATALOGS, currentLocale, key);
|
||||
if (entry === undefined) {
|
||||
warnMissingKey(key, `not in ${currentLocale} or en catalog`);
|
||||
return key;
|
||||
}
|
||||
if (
|
||||
currentLocale !== DEFAULT_LOCALE &&
|
||||
!Object.hasOwn(CATALOGS[currentLocale] || {}, key)
|
||||
) {
|
||||
warnMissingKey(key, `missing in ${currentLocale}, using en fallback`);
|
||||
}
|
||||
const pattern =
|
||||
typeof entry === 'string' ? entry : selectPluralPattern(entry, values, key);
|
||||
return interpolate(pattern, values);
|
||||
}
|
||||
|
||||
/*
|
||||
* Intl formatters are memoized per (kind, locale, options): the constructors
|
||||
* are expensive and stage-3 extraction will format numbers and dates on panel
|
||||
* refresh paths. The cache is cleared whenever the active locale changes.
|
||||
* are expensive and panels format on refresh paths. The cache is cleared
|
||||
* whenever the active locale changes.
|
||||
*/
|
||||
const formatterCache = new Map();
|
||||
|
||||
@@ -230,12 +149,8 @@ export function formatDate(value, options) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale-aware relative time (Intl.RelativeTimeFormat). `value` is the signed
|
||||
* distance in `unit`, e.g. formatRelativeTime(-5, 'minute') →
|
||||
* '5 minutes ago' (en) / 'hace 5 minutos' (es).
|
||||
* @param {number} value Signed distance in `unit`.
|
||||
* @param {string} [unit] Intl unit ('second', 'minute', 'hour', 'day', …).
|
||||
* @param {object} [options] Intl.RelativeTimeFormat options (numeric, style).
|
||||
* Locale-aware relative time (Intl.RelativeTimeFormat), e.g.
|
||||
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад'.
|
||||
*/
|
||||
export function formatRelativeTime(value, unit = 'second', options) {
|
||||
return cachedFormatter('relative', Intl.RelativeTimeFormat, {
|
||||
@@ -244,54 +159,46 @@ export function formatRelativeTime(value, unit = 'second', options) {
|
||||
}).format(value, unit);
|
||||
}
|
||||
|
||||
/** DOM write targets for applyDocumentTranslations: textContent plus the
|
||||
* three presentation attributes vision.md requires to agree with visible
|
||||
* labels. Never innerHTML — translation functions return text. */
|
||||
/** Collapse markup whitespace so a message reads the same in HTML and JS. */
|
||||
export function normalizeMarkupText(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Markup opt-in: the element's own English text or attribute is the source.
|
||||
* <span data-i18n>Normal</span>
|
||||
* <button title="Pin" data-i18n-title>…</button>
|
||||
* Also data-i18n-aria-label and data-i18n-placeholder.
|
||||
*/
|
||||
const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([
|
||||
Object.freeze({
|
||||
attribute: 'data-i18n',
|
||||
apply(element, text) {
|
||||
element.textContent = text;
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
attribute: 'data-i18n-title',
|
||||
apply(element, text) {
|
||||
element.setAttribute('title', text);
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
attribute: 'data-i18n-aria-label',
|
||||
apply(element, text) {
|
||||
element.setAttribute('aria-label', text);
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
attribute: 'data-i18n-placeholder',
|
||||
apply(element, text) {
|
||||
element.setAttribute('placeholder', text);
|
||||
},
|
||||
}),
|
||||
['data-i18n', null],
|
||||
['data-i18n-title', 'title'],
|
||||
['data-i18n-aria-label', 'aria-label'],
|
||||
['data-i18n-placeholder', 'placeholder'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Apply catalog text to every element carrying an i18n attribute in `doc`.
|
||||
* Elements whose key resolves nowhere are left untouched (warned in dev).
|
||||
* @param {Document} doc
|
||||
* Translate every opted-in element in `doc`. Runs once at boot; switching
|
||||
* locale reloads the page.
|
||||
* @param {Document|Element} doc
|
||||
* @returns {number} Count of element/attribute writes performed.
|
||||
*/
|
||||
export function applyDocumentTranslations(doc) {
|
||||
if (typeof doc?.querySelectorAll !== 'function') return 0;
|
||||
if (!CATALOGS[currentLocale]) return 0;
|
||||
let applied = 0;
|
||||
for (const target of DOCUMENT_TRANSLATION_TARGETS) {
|
||||
for (const element of doc.querySelectorAll(`[${target.attribute}]`)) {
|
||||
const key = element.getAttribute?.(target.attribute);
|
||||
if (!key) continue;
|
||||
if (!hasMessage(key)) {
|
||||
warnMissingKey(key, `attribute ${target.attribute}`);
|
||||
continue;
|
||||
}
|
||||
target.apply(element, t(key));
|
||||
for (const [marker, attribute] of DOCUMENT_TRANSLATION_TARGETS) {
|
||||
for (const element of doc.querySelectorAll(`[${marker}]`)) {
|
||||
const source = attribute
|
||||
? element.getAttribute(attribute)
|
||||
: normalizeMarkupText(element.textContent);
|
||||
if (!source) continue;
|
||||
const text = t(source);
|
||||
if (text === source) continue;
|
||||
if (attribute) element.setAttribute(attribute, text);
|
||||
else element.textContent = text;
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
@@ -300,13 +207,8 @@ export function applyDocumentTranslations(doc) {
|
||||
|
||||
/**
|
||||
* Persist a locale choice, then reload preserving window.location.hash exactly
|
||||
* and stripping ?lang from the search string — the override is one-shot and
|
||||
* must never graduate into the stored URL. Storage is best-effort: where
|
||||
* storage is blocked (private mode) the preference simply will not survive.
|
||||
* @param {string} locale Locale the user chose (normalized here).
|
||||
* @param {object} [deps]
|
||||
* @param {{href: string, assign: Function}} [deps.location]
|
||||
* @param {object} [deps.storage] Injected localStorage-like store.
|
||||
* and stripping ?lang from the search string, so the override is one-shot.
|
||||
* Storage is best-effort: where it is blocked the choice will not survive.
|
||||
* @returns {boolean} true when a reload was triggered.
|
||||
*/
|
||||
export function persistLocaleAndReload(
|
||||
@@ -320,20 +222,16 @@ export function persistLocaleAndReload(
|
||||
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
|
||||
writeStoredLocale(normalized, storage);
|
||||
if (!location || typeof location.assign !== 'function') return false;
|
||||
const stripLang = () => {
|
||||
try {
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.delete('lang');
|
||||
return url.href;
|
||||
} catch {
|
||||
return location.href;
|
||||
}
|
||||
};
|
||||
// location.assign() to the byte-identical URL performs NO navigation in
|
||||
// real browsers (the common case: no ?lang present, hash unchanged), so a
|
||||
// stored locale change would never reload. Strip ?lang via replaceState
|
||||
// (no extra history entry) and force a real reload instead.
|
||||
const target = stripLang();
|
||||
let target = location.href;
|
||||
try {
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.delete('lang');
|
||||
target = url.href;
|
||||
} catch {
|
||||
// Keep the current href.
|
||||
}
|
||||
// location.assign() to the identical URL does not navigate, so strip ?lang
|
||||
// in place and force a real reload instead.
|
||||
try {
|
||||
history?.replaceState?.(null, '', target);
|
||||
} catch {
|
||||
|
||||
+29
-256
@@ -1,249 +1,52 @@
|
||||
// Locale identity for God's Eye View (i18n phase 1).
|
||||
//
|
||||
// English is the default-without-configuration, the ALWAYS-shipped fallback
|
||||
// catalog, and the compatibility baseline (docs/TRANSLATORS.md). It is the
|
||||
// only locale with a shipped catalog in this foundation change: additional
|
||||
// locales land as stacked follow-up PRs, one per locale, each following the
|
||||
// recipe in docs/TRANSLATORS.md. Which pair the app actually offers is
|
||||
// configured at build/dev time through GEV_DEFAULT_LOCALE /
|
||||
// GEV_SECONDARY_LOCALE (vite.config.js client defines) and resolved here —
|
||||
// pair members are validated against the shipped catalogs, so while only
|
||||
// English ships the pair degenerates to en-only (a single EN button).
|
||||
// This module owns ONLY locale resolution and document language metadata —
|
||||
// catalogs and translation helpers live in src/i18n/index.js.
|
||||
// Which locale the page runs in: ?lang= → stored choice → browser languages →
|
||||
// English. Only shipped locales are ever selected.
|
||||
|
||||
/** Durable preference key. Matches the repo convention gev:<name>:v1. */
|
||||
export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
|
||||
|
||||
/**
|
||||
* Locales with a shipped catalog. 'en' is the fallback catalog locale
|
||||
* (DEFAULT_LOCALE); every entry here can be normalized and looked up, though
|
||||
* only locales in the configured PAIR (see resolveLocalePair) are offered in
|
||||
* the UI and accepted during resolution. A locale PR appends its code here
|
||||
* alongside its catalogs (docs/TRANSLATORS.md) — nothing else flips shipping.
|
||||
*/
|
||||
export const CATALOG_LOCALES = Object.freeze(['en']);
|
||||
|
||||
/**
|
||||
* Locale codes normalizeLocale folds regional variants for. Kept ahead of
|
||||
* shipping on purpose: normalization is locale-code hygiene ('es-MX' → 'es'),
|
||||
* while SHIPPING is catalog-driven through CATALOG_LOCALES — so a locale PR
|
||||
* edits only CATALOG_LOCALES plus its catalog files, and a code listed here
|
||||
* without a catalog normalizes but is never offerable or resolvable.
|
||||
*/
|
||||
const NORMALIZABLE_LOCALES = Object.freeze(['en', 'es', 'fr', 'ru', 'uk']);
|
||||
|
||||
/** Fallback catalog locale and no-config default: always English. */
|
||||
/** Source language and fallback. */
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
/**
|
||||
* Built-in pair used when no env/config shapes one, or a configured one is
|
||||
* unusable. English-only while English is the only shipped catalog: the
|
||||
* selector degenerates to a single EN button until a secondary locale PR
|
||||
* restores the built-in pair.
|
||||
* Shipped locales, in selector order. Adding one: locales/<code>.json, an
|
||||
* import in index.js, and an entry here (see docs/TRANSLATORS.md).
|
||||
*/
|
||||
const FALLBACK_PAIR = Object.freeze({
|
||||
defaultLocale: 'en',
|
||||
secondaryLocale: 'en',
|
||||
});
|
||||
export const LOCALES = Object.freeze(['en', 'ru']);
|
||||
|
||||
/** Document metadata per normalizable locale code. All of them are LTR. */
|
||||
export const LOCALE_METADATA = Object.freeze({
|
||||
en: Object.freeze({ dir: 'ltr' }),
|
||||
es: Object.freeze({ dir: 'ltr' }),
|
||||
fr: Object.freeze({ dir: 'ltr' }),
|
||||
ru: Object.freeze({ dir: 'ltr' }),
|
||||
uk: Object.freeze({ dir: 'ltr' }),
|
||||
});
|
||||
/** Each locale named in its own language, for the selector. */
|
||||
export const LOCALE_NAMES = Object.freeze({ en: 'English', ru: 'Русский' });
|
||||
|
||||
/** Right-to-left locales. */
|
||||
const RTL_LOCALES = Object.freeze([]);
|
||||
|
||||
/**
|
||||
* Map any candidate language tag to a known locale code. 'es'/'fr'/'ru'/'uk'
|
||||
* and every regional variant ('es-MX', 'fr_CA', 'ru-KZ', 'uk_UA') resolve to the
|
||||
* primary tag; anything unknown (or not a string) resolves to null so the
|
||||
* caller's precedence chain can keep looking. Knowing a code is hygiene, not
|
||||
* shipping: offering/resolving a locale additionally requires a catalog
|
||||
* (CATALOG_LOCALES) — see resolveLocalePair.
|
||||
* @param {*} candidate Raw locale tag (?lang= value, stored value, navigator entry).
|
||||
* @returns {'en'|'es'|'fr'|'ru'|'uk'|null}
|
||||
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru'.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function normalizeLocale(candidate) {
|
||||
if (typeof candidate !== 'string') return null;
|
||||
const primary = candidate.trim().toLowerCase().split(/[-_]/)[0];
|
||||
return NORMALIZABLE_LOCALES.includes(primary) ? primary : null;
|
||||
return LOCALES.includes(primary) ? primary : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the configured pair from the two client defines. Both spellings are
|
||||
* replaced TEXTUALLY by Vite (vite.config.js `define`), so they must stay
|
||||
* verbatim member expressions. Under node:test `import.meta.env` is absent
|
||||
* and the first access throws; the catch turns that into "use the defaults".
|
||||
* @returns {{defaultLocale?: string, secondaryLocale?: string}}
|
||||
*/
|
||||
function readEnvLocalePair() {
|
||||
function queryLocale(location) {
|
||||
try {
|
||||
return {
|
||||
defaultLocale: import.meta.env.GEV_DEFAULT_LOCALE,
|
||||
secondaryLocale: import.meta.env.GEV_SECONDARY_LOCALE,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** A blank string means "unset" (an empty .env line), not an invalid value. */
|
||||
function orBuiltIn(value, builtIn) {
|
||||
return typeof value === 'string' && value.trim() ? value : builtIn;
|
||||
}
|
||||
|
||||
/** Development-only diagnostics, same gate as warnMissingKey in index.js. */
|
||||
function warnInvalidPair(rawDefault, rawSecondary) {
|
||||
// Vite replaces import.meta.env at build time; under node:test it is absent,
|
||||
// so warnings never fire in CI — only on the dev server.
|
||||
if (import.meta.env?.DEV !== true) return;
|
||||
console.warn(
|
||||
`[i18n] unusable locale pair ${JSON.stringify(String(rawDefault))}/` +
|
||||
`${JSON.stringify(String(rawSecondary))} — offering English only`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the offered locale pair. With no argument the two client defines are
|
||||
* read (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE; blank means the built-in
|
||||
* value); an explicit { defaultLocale, secondaryLocale } injects a pair for
|
||||
* tests and embedders — injected fields never fall through to the env.
|
||||
*
|
||||
* SHIPPING IS CATALOG-DRIVEN: both members must name a locale in
|
||||
* CATALOG_LOCALES. An unknown code, an unshipped code (a locale PR not yet
|
||||
* merged), or a degenerate pair (the same locale twice) returns the built-in
|
||||
* pair instead, and a dev-only console.warn explains why — except when the
|
||||
* raw values already ARE the built-in pair, which is the normal unconfigured
|
||||
* state and must stay silent. While English is the only shipped catalog the
|
||||
* built-in pair is en-only, so the selector renders a single EN button.
|
||||
*
|
||||
* availableLocales is the deduped [default, secondary, 'en'] triple — English
|
||||
* is always shippable because its catalog is the unconditional fallback.
|
||||
* @param {{defaultLocale?: string, secondaryLocale?: string}} [config]
|
||||
* @returns {{defaultLocale: string, secondaryLocale: string, availableLocales: readonly string[]}} Frozen.
|
||||
*/
|
||||
export function resolveLocalePair(config) {
|
||||
const injected =
|
||||
typeof config === 'object' &&
|
||||
config !== null &&
|
||||
(config.defaultLocale !== undefined ||
|
||||
config.secondaryLocale !== undefined);
|
||||
const raw = injected ? config : readEnvLocalePair();
|
||||
const rawDefault = orBuiltIn(raw?.defaultLocale, FALLBACK_PAIR.defaultLocale);
|
||||
const rawSecondary = orBuiltIn(
|
||||
raw?.secondaryLocale,
|
||||
FALLBACK_PAIR.secondaryLocale,
|
||||
);
|
||||
const defaultLocale = normalizeLocale(rawDefault);
|
||||
const secondaryLocale = normalizeLocale(rawSecondary);
|
||||
const offerable = Boolean(
|
||||
defaultLocale &&
|
||||
secondaryLocale &&
|
||||
CATALOG_LOCALES.includes(defaultLocale) &&
|
||||
CATALOG_LOCALES.includes(secondaryLocale),
|
||||
);
|
||||
if (!offerable || defaultLocale === secondaryLocale) {
|
||||
// The built-in pair itself is never a misconfiguration: only a raw value
|
||||
// that had to be corrected warns on the dev server.
|
||||
if (
|
||||
rawDefault !== FALLBACK_PAIR.defaultLocale ||
|
||||
rawSecondary !== FALLBACK_PAIR.secondaryLocale
|
||||
) {
|
||||
warnInvalidPair(rawDefault, rawSecondary);
|
||||
}
|
||||
return pairOf(FALLBACK_PAIR.defaultLocale, FALLBACK_PAIR.secondaryLocale);
|
||||
}
|
||||
return pairOf(defaultLocale, secondaryLocale);
|
||||
}
|
||||
|
||||
/** Freeze the deduped pair + always-shipped English. */
|
||||
function pairOf(defaultLocale, secondaryLocale) {
|
||||
const availableLocales = [
|
||||
...new Set([defaultLocale, secondaryLocale, DEFAULT_LOCALE]),
|
||||
];
|
||||
return Object.freeze({
|
||||
defaultLocale,
|
||||
secondaryLocale,
|
||||
availableLocales: Object.freeze(availableLocales),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Locales the app offers and accepts, in selector order: the configured
|
||||
* default, the secondary locale, then English if not already present.
|
||||
* @param {{defaultLocale?: string, secondaryLocale?: string}} [config] Injectable pair (tests/embedders).
|
||||
* @returns {readonly string[]}
|
||||
*/
|
||||
export function availableLocales(config) {
|
||||
return resolveLocalePair(config).availableLocales;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot `?lang=` override, read from the SEARCH string only.
|
||||
*
|
||||
* The override is never persisted, and it must never leak into the URL hash or
|
||||
* share links: it dies with the URL it arrived in (persistLocaleAndReload in
|
||||
* src/i18n/index.js strips it when a choice is stored). An unsupported value —
|
||||
* or a shipped locale outside the configured pair — is ignored rather than
|
||||
* forced to English; the rest of the precedence chain still gets to speak.
|
||||
* @param {{search?: string}|null} location
|
||||
* @param {readonly string[]} availableLocales Resolved pair locales.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function queryLocaleOverride(location, availableLocales) {
|
||||
const search = location?.search;
|
||||
if (!search) return null;
|
||||
try {
|
||||
return normalizeIfAvailable(
|
||||
new URLSearchParams(search).get('lang'),
|
||||
availableLocales,
|
||||
);
|
||||
return normalizeLocale(new URLSearchParams(location?.search).get('lang'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize a candidate, keeping it only when the pair offers that locale. */
|
||||
function normalizeIfAvailable(candidate, availableLocales) {
|
||||
const normalized = normalizeLocale(candidate);
|
||||
return normalized && availableLocales.includes(normalized)
|
||||
? normalized
|
||||
: null;
|
||||
}
|
||||
|
||||
/*
|
||||
* STORAGE ACCESS IS LAZY AND GUARDED (same pattern as src/firstRunExperience.js):
|
||||
* the `globalThis.localStorage` getter itself can throw SecurityError in Safari
|
||||
* private mode or under enterprise policies, so it is only touched inside a
|
||||
* try/catch at the moment of use — never in a default parameter. An injected
|
||||
* `storage` (tests, embedders) short-circuits the global entirely; `undefined`
|
||||
* means "use the global".
|
||||
*/
|
||||
|
||||
/** Read the stored preference, treating every failure as "nothing stored".
|
||||
* A stored locale outside the current pair reads as absent. */
|
||||
function readStoredLocale(storage, availableLocales) {
|
||||
function readStoredLocale(storage) {
|
||||
try {
|
||||
const store = storage !== undefined ? storage : globalThis.localStorage;
|
||||
return normalizeIfAvailable(
|
||||
store?.getItem?.(LOCALE_STORAGE_KEY),
|
||||
availableLocales,
|
||||
);
|
||||
return normalizeLocale(store?.getItem?.(LOCALE_STORAGE_KEY));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the stored preference, best-effort. Never throws. The stored value is
|
||||
* any known locale code (pair membership is re-checked at resolution time, so
|
||||
* a choice made under one configuration — or for a locale whose catalog has
|
||||
* not shipped yet — degrades gracefully to the pair's default).
|
||||
* @returns {boolean} true only if the value actually landed.
|
||||
*/
|
||||
/** Store the choice; false when storage is unavailable. */
|
||||
export function writeStoredLocale(locale, storage) {
|
||||
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
|
||||
try {
|
||||
@@ -252,63 +55,33 @@ export function writeStoredLocale(locale, storage) {
|
||||
store.setItem(LOCALE_STORAGE_KEY, normalized);
|
||||
return true;
|
||||
} catch {
|
||||
// Privacy-restricted or quota-exhausted storage must never break the app.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active locale. Precedence (docs/TRANSLATORS.md):
|
||||
*
|
||||
* ?lang=<locale> → stored preference → navigator.languages → configured default
|
||||
*
|
||||
* Every step only accepts locales in the configured pair (which always
|
||||
* includes English); each step that yields nothing (absent or out-of-pair)
|
||||
* defers to the next. Without env configuration the built-in pair is en-only
|
||||
* (English is the only shipped catalog), so every step resolves English —
|
||||
* byte-identical to the pre-configuration resolution chain.
|
||||
* @param {object} [input]
|
||||
* @param {{search?: string, href?: string}|null} [input.location] Location to read ?lang= from.
|
||||
* @param {object|null} [input.storage] Injected localStorage-like store; `undefined` uses the global.
|
||||
* @param {readonly string[]|null} [input.languages] Browser language list.
|
||||
* @param {{defaultLocale?: string, secondaryLocale?: string}} [input.config] Injectable pair (tests/embedders); omit to read the client defines.
|
||||
* @returns {string}
|
||||
*/
|
||||
/** Resolve the boot locale. Every input is injectable for tests. */
|
||||
export function resolveLocale({
|
||||
location = globalThis.location,
|
||||
storage,
|
||||
languages = globalThis.navigator?.languages,
|
||||
config,
|
||||
} = {}) {
|
||||
const { availableLocales: offered, defaultLocale } =
|
||||
resolveLocalePair(config);
|
||||
const override = queryLocaleOverride(location, offered);
|
||||
if (override) return override;
|
||||
const stored = readStoredLocale(storage, offered);
|
||||
const fromQuery = queryLocale(location);
|
||||
if (fromQuery) return fromQuery;
|
||||
const stored = readStoredLocale(storage);
|
||||
if (stored) return stored;
|
||||
if (Array.isArray(languages)) {
|
||||
for (const candidate of languages) {
|
||||
const normalized = normalizeIfAvailable(candidate, offered);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
for (const candidate of Array.isArray(languages) ? languages : []) {
|
||||
const normalized = normalizeLocale(candidate);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
return defaultLocale;
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflect the active locale on the document: `<html lang>` and `<html dir>`
|
||||
* must always agree with the language actually being rendered.
|
||||
* @param {Document} doc Document whose documentElement carries the metadata.
|
||||
* @param {string} locale Locale to apply (normalized here).
|
||||
* @returns {boolean} true when the metadata was written.
|
||||
*/
|
||||
/** Set <html lang> and <html dir>. */
|
||||
export function applyDocumentLanguage(doc, locale = DEFAULT_LOCALE) {
|
||||
const root = doc?.documentElement;
|
||||
if (!root) return false;
|
||||
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
|
||||
root.lang = normalized;
|
||||
root.dir = (
|
||||
LOCALE_METADATA[normalized] || LOCALE_METADATA[DEFAULT_LOCALE]
|
||||
).dir;
|
||||
root.dir = RTL_LOCALES.includes(normalized) ? 'rtl' : 'ltr';
|
||||
return true;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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}.',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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": "Выполнение команды"
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -20,7 +20,7 @@ import {
|
||||
* the chip and the dialog are removed outright.
|
||||
*/
|
||||
|
||||
import { t, hasMessage } from './i18n/index.js';
|
||||
import { t, msg, tn } from './i18n/index.js';
|
||||
|
||||
/**
|
||||
* Chip label — pure, exported for tests. The exact English values are pinned
|
||||
@@ -32,8 +32,12 @@ import { t, hasMessage } from './i18n/index.js';
|
||||
export function keySetupChipLabel(status) {
|
||||
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0));
|
||||
return missing > 0
|
||||
? t('setup.keySetup.chipWaiting', { count: missing })
|
||||
: t('setup.keySetup.chipReady');
|
||||
? tn(
|
||||
missing,
|
||||
'POWER UP · {count} KEY WAITING',
|
||||
'POWER UP · {count} KEYS WAITING',
|
||||
)
|
||||
: t('POWERED UP');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,10 +190,7 @@ function buildRow(documentRef, key) {
|
||||
|
||||
const unlocks = documentRef.createElement('p');
|
||||
unlocks.className = 'key-setup-unlocks';
|
||||
// The registry `unlocks` text localizes per registry id; an id missing from
|
||||
// the catalog falls back to the server-provided English copy.
|
||||
const unlocksKey = `setup.keySetup.unlocks.${key.id}`;
|
||||
unlocks.textContent = hasMessage(unlocksKey) ? t(unlocksKey) : key.unlocks;
|
||||
unlocks.textContent = t(key.unlocks);
|
||||
|
||||
row.append(head, unlocks);
|
||||
if (!external) {
|
||||
@@ -214,8 +215,10 @@ function buildRow(documentRef, key) {
|
||||
remove.type = 'button';
|
||||
remove.className = 'key-setup-remove';
|
||||
remove.dataset.keySetupRemove = JSON.stringify(key.envVars);
|
||||
remove.textContent = t('setup.keySetup.row.remove');
|
||||
remove.title = t('setup.keySetup.row.removeTitle', { title: key.title });
|
||||
remove.textContent = t('REMOVE');
|
||||
remove.title = t("Remove {title} from this app's saved keys", {
|
||||
title: key.title,
|
||||
});
|
||||
fields.append(remove);
|
||||
}
|
||||
row.append(fields);
|
||||
@@ -380,8 +383,8 @@ export async function initKeySetup({
|
||||
|
||||
const storeLabel = () =>
|
||||
status?.store === 'pinokio-environment'
|
||||
? t('setup.keySetup.store.pinokio')
|
||||
: t('setup.keySetup.store.env');
|
||||
? t('your app configuration')
|
||||
: t('your local .env');
|
||||
|
||||
// doneMessageKey names the whole saved/removed sentence; the storage
|
||||
// destination is interpolated so the {store} phrase stays one spelling.
|
||||
@@ -392,7 +395,7 @@ export async function initKeySetup({
|
||||
)?.set;
|
||||
busy = true;
|
||||
applyButton?.setAttribute('aria-disabled', 'true');
|
||||
say(t('setup.keySetup.status.saving'));
|
||||
say(t('Saving…'));
|
||||
try {
|
||||
const response = await doFetch('/api/setup/keys', {
|
||||
method: 'POST',
|
||||
@@ -406,7 +409,7 @@ export async function initKeySetup({
|
||||
// payload.error is a server-authored message (API value) — shown as-is.
|
||||
say(
|
||||
payload.error ||
|
||||
t('setup.keySetup.status.saveFailed', { status: response.status }),
|
||||
t('Save failed ({status}).', { status: response.status }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -440,7 +443,7 @@ export async function initKeySetup({
|
||||
say(t(doneMessageKey, { store: storeLabel() }));
|
||||
} catch (error) {
|
||||
say(
|
||||
t('setup.keySetup.status.saveFailedDetail', {
|
||||
t('Save failed: {detail}', {
|
||||
detail: error?.message || error,
|
||||
}),
|
||||
);
|
||||
@@ -460,10 +463,13 @@ export async function initKeySetup({
|
||||
})),
|
||||
);
|
||||
if (!Object.keys(updates).length) {
|
||||
say(t('setup.keySetup.status.pasteFirst'));
|
||||
say(t('Paste at least one key first.'));
|
||||
return;
|
||||
}
|
||||
await submitUpdates(updates, 'setup.keySetup.status.saved');
|
||||
await submitUpdates(
|
||||
updates,
|
||||
msg('Saved to {store}. Restarting — this page reloads itself.'),
|
||||
);
|
||||
};
|
||||
|
||||
chip.addEventListener('click', openDialog);
|
||||
@@ -579,11 +585,11 @@ export async function initKeySetup({
|
||||
// a deliberate two-step the lure cannot pre-satisfy.
|
||||
const ok =
|
||||
typeof globalThis.confirm !== 'function' ||
|
||||
globalThis.confirm(t('setup.keySetup.confirm.remove'));
|
||||
globalThis.confirm(t('Remove this key from your saved configuration?'));
|
||||
if (!ok) return;
|
||||
void submitUpdates(
|
||||
Object.fromEntries(envVars.map((name) => [name, null])),
|
||||
'setup.keySetup.status.removed',
|
||||
msg('Removed from {store}. Restarting — this page reloads itself.'),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+17
-10
@@ -32,11 +32,15 @@ export const KEY_SETUP_APPEND_HEADER =
|
||||
* bundle by design (restrict them at the provider, per SECURITY.md).
|
||||
* `hidden` keeps advanced configuration out of the panel and missing-key count.
|
||||
*/
|
||||
// Marks `unlocks` copy for i18n extraction; Provider Settings translates it
|
||||
// with t(). Kept local so the server can import this module without i18n.
|
||||
const msg = (text) => text;
|
||||
|
||||
export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'google-maps',
|
||||
title: 'GOOGLE MAPS',
|
||||
unlocks: 'The photorealistic 3D planet + place search',
|
||||
unlocks: msg('The photorealistic 3D planet + place search'),
|
||||
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
|
||||
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
|
||||
tier: 'metered',
|
||||
@@ -45,7 +49,9 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'google-maps-server',
|
||||
title: 'GOOGLE MAPS — SERVER',
|
||||
unlocks: 'Places context + Street View fallback; optional separate key',
|
||||
unlocks: msg(
|
||||
'Places context + Street View fallback; optional separate key',
|
||||
),
|
||||
getUrl:
|
||||
'https://developers.google.com/maps/documentation/places/web-service/get-api-key',
|
||||
envVars: Object.freeze(['GOOGLE_MAPS_SERVER_API_KEY']),
|
||||
@@ -55,7 +61,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'openai',
|
||||
title: 'OPENAI',
|
||||
unlocks: 'Voice control — API key or ChatGPT OAuth',
|
||||
unlocks: msg('Voice control — API key or ChatGPT OAuth'),
|
||||
getUrl: 'https://platform.openai.com/api-keys',
|
||||
envVars: Object.freeze(['OPENAI_API_KEY']),
|
||||
tier: 'metered',
|
||||
@@ -63,7 +69,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'aisstream',
|
||||
title: 'AISSTREAM',
|
||||
unlocks: 'Live ships, worldwide',
|
||||
unlocks: msg('Live ships, worldwide'),
|
||||
getUrl: 'https://aisstream.io',
|
||||
envVars: Object.freeze(['AISSTREAM_API_KEY']),
|
||||
tier: 'free',
|
||||
@@ -71,7 +77,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'firms',
|
||||
title: 'NASA FIRMS',
|
||||
unlocks: 'Live active-fire detections',
|
||||
unlocks: msg('Live active-fire detections'),
|
||||
getUrl: 'https://firms.modaps.eosdis.nasa.gov/api/map_key/',
|
||||
envVars: Object.freeze(['FIRMS_MAP_KEY']),
|
||||
tier: 'free',
|
||||
@@ -79,7 +85,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'tomtom',
|
||||
title: 'TOMTOM',
|
||||
unlocks: 'Real live traffic (keyless runs a simulation)',
|
||||
unlocks: msg('Real live traffic (keyless runs a simulation)'),
|
||||
getUrl: 'https://my.tomtom.com/keys',
|
||||
envVars: Object.freeze(['TOMTOM_API_KEY']),
|
||||
tier: 'free',
|
||||
@@ -87,7 +93,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'cesium-ion',
|
||||
title: 'CESIUM ION',
|
||||
unlocks: 'Bing imagery map stacks + world terrain',
|
||||
unlocks: msg('Bing imagery map stacks + world terrain'),
|
||||
getUrl: 'https://ion.cesium.com/tokens',
|
||||
envVars: Object.freeze(['CESIUM_ION_TOKEN']),
|
||||
tier: 'free',
|
||||
@@ -96,7 +102,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'opensky',
|
||||
title: 'OPENSKY',
|
||||
unlocks: 'More flight-polling credits (anonymous works without)',
|
||||
unlocks: msg('More flight-polling credits (anonymous works without)'),
|
||||
getUrl: 'https://opensky-network.org',
|
||||
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
|
||||
tier: 'free',
|
||||
@@ -104,8 +110,9 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'mapillary',
|
||||
title: 'MAPILLARY',
|
||||
unlocks:
|
||||
unlocks: msg(
|
||||
'Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token',
|
||||
),
|
||||
getUrl: 'https://www.mapillary.com/dashboard/developers',
|
||||
envVars: Object.freeze(['MAPILLARY_CLIENT_TOKEN']),
|
||||
tier: 'free',
|
||||
@@ -114,7 +121,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'launch-library',
|
||||
title: 'LAUNCH LIBRARY',
|
||||
unlocks: 'Higher space-missions request allowance',
|
||||
unlocks: msg('Higher space-missions request allowance'),
|
||||
getUrl: 'https://thespacedevs.com',
|
||||
envVars: Object.freeze(['LL2_API_TOKEN']),
|
||||
tier: 'free',
|
||||
|
||||
@@ -57,8 +57,8 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
function hidePanel() {
|
||||
if (layerState.panel) {
|
||||
const markup = `<div class="military-awareness-standby">
|
||||
<strong>${layerState.enabled ? t('layers.awareness.standbyReady') : t('layers.awareness.standbyOff')}</strong>
|
||||
<span>${layerState.enabled ? t('layers.awareness.standbySelect') : t('layers.awareness.standbyEnable')}</span>
|
||||
<strong>${layerState.enabled ? t('CONTEXT READY') : t('GLOBAL CONTEXT OFF')}</strong>
|
||||
<span>${layerState.enabled ? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION') : t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY')}</span>
|
||||
</div>`;
|
||||
layerState.panel.hidden = false;
|
||||
if (layerState.panelMarkup !== markup) {
|
||||
@@ -79,11 +79,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
const label = formatAwarenessLabel(item);
|
||||
const targetId = item.icao24 || item.mmsi || item.id;
|
||||
if (!targetId) {
|
||||
return `<li><span class="military-awareness-target unavailable" aria-label="${t('layers.awareness.unavailableAria')}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
|
||||
return `<li><span class="military-awareness-target unavailable" aria-label="${t('Unavailable')}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
|
||||
}
|
||||
const accessibleLabel =
|
||||
label === '—' ? t('layers.awareness.unavailableAria') : label;
|
||||
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('layers.awareness.focusAria', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
|
||||
const accessibleLabel = label === '—' ? t('Unavailable') : label;
|
||||
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('Focus {label}', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
|
||||
})
|
||||
.join('');
|
||||
const pageCount = Math.max(
|
||||
@@ -104,10 +103,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
|
||||
function navigationControlsHtml() {
|
||||
const canPrevious = layerState.navigationIndex > 0;
|
||||
return `<div class="military-awareness-controls" role="group" aria-label="${t('layers.awareness.controlsAria')}">
|
||||
<button type="button" data-awareness-action="previous" title="${t('layers.awareness.previousTitle')}"${canPrevious ? '' : ' disabled'}>${t('layers.awareness.previous')}</button>
|
||||
<button type="button" data-awareness-action="focus">${t('layers.awareness.focus')}</button>
|
||||
<button type="button" data-awareness-action="next" title="${t('layers.awareness.nextTitle')}"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>${t('layers.awareness.next')}</button>
|
||||
return `<div class="military-awareness-controls" role="group" aria-label="${t('Global Context navigation')}">
|
||||
<button type="button" data-awareness-action="previous" title="${t('Previous — prior visited contact in the 250 km window')}"${canPrevious ? '' : ' disabled'}>${t('PREVIOUS')}</button>
|
||||
<button type="button" data-awareness-action="focus">${t('FOCUS')}</button>
|
||||
<button type="button" data-awareness-action="next" title="${t('Next — nearest unvisited contact in the 250 km window')}"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>${t('NEXT')}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -189,7 +188,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
${namedAreasHtml(subject.memberNames)}
|
||||
${navigationControlsHtml()}
|
||||
${cohorts.map(rowHtml).join('')}
|
||||
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>${t('layers.awareness.note')}</p>`;
|
||||
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>${t('Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.')}</p>`;
|
||||
panel.hidden = false;
|
||||
if (layerState.panelMarkup !== markup) {
|
||||
const focusSnapshot = captureAwarenessPanelFocus(panel);
|
||||
|
||||
@@ -138,7 +138,7 @@ export function createRendering({
|
||||
'--compass-rotation',
|
||||
`${-headingDeg}deg`,
|
||||
);
|
||||
layerState.compassHeading.textContent = t('layers.awareness.hdg', {
|
||||
layerState.compassHeading.textContent = t('HDG {value}°', {
|
||||
value: String(headingDeg).padStart(3, '0'),
|
||||
});
|
||||
layerState.compassHeading.style.left = `${geometry.centerX}px`;
|
||||
@@ -224,12 +224,12 @@ export function createRendering({
|
||||
)
|
||||
: null;
|
||||
const bearingText = Number.isFinite(bearing)
|
||||
? t('layers.awareness.brg', {
|
||||
? t('BRG {value}°', {
|
||||
value: String(Math.round(bearing)).padStart(3, '0'),
|
||||
})
|
||||
: t('layers.awareness.brgUnknown');
|
||||
: t('BRG —');
|
||||
const courseText = Number.isFinite(item.track)
|
||||
? ` · ${t('layers.awareness.crs', {
|
||||
? ` · ${t('CRS {value}°', {
|
||||
value: String(Math.round(item.track)).padStart(3, '0'),
|
||||
})}`
|
||||
: '';
|
||||
|
||||
@@ -19,10 +19,9 @@ const WITHIN_KM_RE = /^WITHIN (\d+) KM$/;
|
||||
|
||||
function localizeCoverageLabel(label) {
|
||||
if (typeof label !== 'string' || !label) return label;
|
||||
if (label === 'CURRENT VIEWPORT ONLY')
|
||||
return t('layers.awareness.coverage.viewport');
|
||||
if (label === 'CURRENT VIEWPORT ONLY') return t('CURRENT VIEWPORT ONLY');
|
||||
const within = WITHIN_KM_RE.exec(label);
|
||||
if (within) return t('layers.awareness.coverage.within', { km: within[1] });
|
||||
if (within) return t('WITHIN {km} KM', { km: within[1] });
|
||||
return label;
|
||||
}
|
||||
|
||||
@@ -94,7 +93,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
cohorts: [
|
||||
{
|
||||
id: 'flights',
|
||||
label: t('layers.awareness.cohort.flights'),
|
||||
label: t('Flights'),
|
||||
source: flightsState.stats.source || SOURCE_LABEL.flights,
|
||||
provenance: retainedProvenance(
|
||||
'flights',
|
||||
@@ -113,7 +112,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
},
|
||||
{
|
||||
id: 'military',
|
||||
label: t('layers.awareness.cohort.military'),
|
||||
label: t('Military flights'),
|
||||
source: militaryState.stats.source || SOURCE_LABEL.military,
|
||||
provenance: retainedProvenance(
|
||||
'military',
|
||||
@@ -132,7 +131,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
},
|
||||
{
|
||||
id: 'ais-live-vessels',
|
||||
label: t('layers.awareness.cohort.vessels'),
|
||||
label: t('AIS vessels'),
|
||||
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
|
||||
provenance: retainedProvenance(
|
||||
'ais-live-vessels',
|
||||
@@ -147,7 +146,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
},
|
||||
{
|
||||
id: 'military-installations',
|
||||
label: t('layers.awareness.cohort.installations'),
|
||||
label: t('Mapped installations'),
|
||||
source:
|
||||
installationsState.stats.source ||
|
||||
SOURCE_LABEL['military-installations'],
|
||||
@@ -159,7 +158,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
),
|
||||
coverage:
|
||||
localizeCoverageLabel(installationsState.stats.coverageLabel) ||
|
||||
t('layers.awareness.coverage.viewport'),
|
||||
t('CURRENT VIEWPORT ONLY'),
|
||||
summary: parts.queries.summarizeInstallationViewport(
|
||||
installations,
|
||||
installationsState,
|
||||
|
||||
Vendored
+2
-2
@@ -68,10 +68,10 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
if (layerState._loading) {
|
||||
stats.loadingLabel =
|
||||
layerState._activeCityIds.size > 0
|
||||
? t('layers.bike.loadingSyncing', {
|
||||
? t('syncing {count} city feeds...', {
|
||||
count: layerState._activeCityIds.size,
|
||||
})
|
||||
: t('layers.bike.loadingScanning');
|
||||
: t('scanning nearby systems...');
|
||||
}
|
||||
if (layerState._error) stats.error = layerState._error;
|
||||
return stats;
|
||||
|
||||
@@ -24,8 +24,8 @@ export function createSelection({
|
||||
const stationLabel =
|
||||
stationName ||
|
||||
(record?.stationId
|
||||
? t('layers.bike.stationWithId', { id: record.stationId })
|
||||
: t('layers.bike.stationFallback'));
|
||||
? t('Station {id}', { id: record.stationId })
|
||||
: t('Station'));
|
||||
const bikes = Number.isFinite(record?.bikesAvailable)
|
||||
? record.bikesAvailable
|
||||
: '?';
|
||||
@@ -36,17 +36,18 @@ export function createSelection({
|
||||
|
||||
const lines = [
|
||||
stationLabel,
|
||||
t('layers.bike.card.capacity', { bikes, docks, capacity }),
|
||||
t('🚲 {bikes} avail · {docks} docks · {capacity} cap', {
|
||||
bikes,
|
||||
docks,
|
||||
capacity,
|
||||
}),
|
||||
];
|
||||
|
||||
// Append warnings for stations that are offline or partially non-operational
|
||||
const abnormal = [];
|
||||
if (record?.isInstalled === false)
|
||||
abnormal.push(t('layers.bike.warning.notInstalled'));
|
||||
if (record?.isRenting === false)
|
||||
abnormal.push(t('layers.bike.warning.notRenting'));
|
||||
if (record?.isReturning === false)
|
||||
abnormal.push(t('layers.bike.warning.notReturning'));
|
||||
if (record?.isInstalled === false) abnormal.push(t('⚠️ Not installed'));
|
||||
if (record?.isRenting === false) abnormal.push(t('⚠️ Not renting'));
|
||||
if (record?.isReturning === false) abnormal.push(t('⚠️ Not returning'));
|
||||
if (abnormal.length > 0) {
|
||||
lines.push(abnormal.join(' · '));
|
||||
}
|
||||
|
||||
@@ -48,6 +48,6 @@ export function headingHudToken(camera) {
|
||||
// The token copy lives in the layers catalog (en is byte-identical to the
|
||||
// literals these replace); presentation stays here, translation at read.
|
||||
return isHeadingEstimated(camera)
|
||||
? t('layers.cctv.summary.hdgEstimated', { value: hdg })
|
||||
: t('layers.cctv.summary.hdg', { value: hdg });
|
||||
? t('HDG {value}° (ESTIMATED)', { value: hdg })
|
||||
: t('HDG {value}°', { value: hdg });
|
||||
}
|
||||
@@ -20,10 +20,13 @@ export function createPresentation({
|
||||
const active = parts.selection.getActiveRecord();
|
||||
if (!active) {
|
||||
return layerState._records.length
|
||||
? t('layers.cctv.summary.standingBy', {
|
||||
count: layerState._records.length,
|
||||
})
|
||||
: t('layers.cctv.summary.empty');
|
||||
? t(
|
||||
'{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE',
|
||||
{
|
||||
count: layerState._records.length,
|
||||
},
|
||||
)
|
||||
: t('No cameras available in catalog.');
|
||||
}
|
||||
|
||||
const area = parts.model.sectorAreaKm2(
|
||||
@@ -37,37 +40,33 @@ export function createPresentation({
|
||||
const calBadge = parts.calibration.deriveCalBadge(active.camera);
|
||||
|
||||
return [
|
||||
t('layers.cctv.summary.city', {
|
||||
t('{city} CCTV', {
|
||||
city: active.camera.city.toUpperCase(),
|
||||
}),
|
||||
`${active.camera.name.toUpperCase()}`,
|
||||
// A synthetic bearing (headingConfidence 'low', no human calibration)
|
||||
// is tagged so a hashed guess never reads as a surveyed facing (#639).
|
||||
headingHudToken(active.camera),
|
||||
t('layers.cctv.summary.fov', {
|
||||
t('FOV {value}°', {
|
||||
value: Math.round(active.camera.fovDeg),
|
||||
}),
|
||||
t('layers.cctv.summary.coverage', { value: area.toFixed(2) }),
|
||||
t('COVERAGE {value}km²', { value: area.toFixed(2) }),
|
||||
overlapCount > 0
|
||||
? t('layers.cctv.summary.overlap', { count: overlapCount })
|
||||
: t('layers.cctv.summary.isolated'),
|
||||
layerState._showProjection
|
||||
? t('layers.cctv.summary.projMonitor')
|
||||
: t('layers.cctv.summary.projOff'),
|
||||
layerState._coverageMode === 'viewshed'
|
||||
? t('layers.cctv.summary.viewshed')
|
||||
: null,
|
||||
t('layers.cctv.summary.cal', {
|
||||
? t('OVERLAP {count} cams', { count: overlapCount })
|
||||
: t('ISOLATED VIEW'),
|
||||
layerState._showProjection ? t('PROJ MONITOR') : t('PROJ OFF'),
|
||||
layerState._coverageMode === 'viewshed' ? t('VIEWSHED') : null,
|
||||
t('CAL {value}', {
|
||||
value: calBadge.replace('-', ' ').toUpperCase(),
|
||||
}),
|
||||
health?.sourceKind
|
||||
? t('layers.cctv.summary.src', {
|
||||
? t('SRC {value}', {
|
||||
value: String(health.sourceKind).toUpperCase(),
|
||||
})
|
||||
: t('layers.cctv.summary.src', {
|
||||
: t('SRC {value}', {
|
||||
value: String(active.camera.feedType || 'image').toUpperCase(),
|
||||
}),
|
||||
t('layers.cctv.summary.context', { value: viewBand.toUpperCase() }),
|
||||
t('{value} CONTEXT', { value: viewBand.toUpperCase() }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
|
||||
@@ -116,7 +116,7 @@ export function createCards({
|
||||
if (!card.interactive) return card;
|
||||
return {
|
||||
...card,
|
||||
accessibilityLabel: t('layers.firms.card.focusAria', {
|
||||
accessibilityLabel: t('Focus fire detection {title}, {details}', {
|
||||
title: card.title,
|
||||
details: card.details.join(', '),
|
||||
}),
|
||||
|
||||
+13
-21
@@ -331,19 +331,17 @@ export function createModel({
|
||||
|
||||
function buildSelectedFireCard(fire, nowMs) {
|
||||
const meta = [
|
||||
t('layers.firms.card.confSuffix', {
|
||||
t('{value} conf', {
|
||||
value: confidenceBucket(fire.confidence),
|
||||
}),
|
||||
];
|
||||
if (fire.acqMs > 0) {
|
||||
const age = formatAge(nowMs - fire.acqMs);
|
||||
if (age) meta.push(t('layers.firms.card.ageSuffix', { value: age }));
|
||||
if (age) meta.push(t('{value} ago', { value: age }));
|
||||
}
|
||||
const sat = satelliteShortName(fire.satellite);
|
||||
meta.push(
|
||||
sat
|
||||
? `${fire.sensor || 'VIIRS'} ${sat}`
|
||||
: fire.sensor || t('layers.firms.card.sensorUnavailable'),
|
||||
sat ? `${fire.sensor || 'VIIRS'} ${sat}` : fire.sensor || t('sensor n/a'),
|
||||
);
|
||||
return {
|
||||
id: `selected-fire:${fireDetectionKey(fire)}`,
|
||||
@@ -353,11 +351,11 @@ export function createModel({
|
||||
cullPosition: fireCullPosition(fire),
|
||||
gapPx: frpPixelSize(fire.frp),
|
||||
accent: accentForSeverity(detectionColorStop(fire).name),
|
||||
title: t('layers.firms.card.fire', { frp: formatFrp(fire.frp) }),
|
||||
title: t('FIRE · {frp} MW', { frp: formatFrp(fire.frp) }),
|
||||
details: [
|
||||
meta.join(' · '),
|
||||
formatLatLon(fire.lat, fire.lon) +
|
||||
(fire.night ? ` · ${t('layers.firms.card.night')}` : ''),
|
||||
(fire.night ? ` · ${t('NIGHT')}` : ''),
|
||||
],
|
||||
selected: true,
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
@@ -390,7 +388,7 @@ export function createModel({
|
||||
cullPosition: candidate.cullPosition || candidate.position,
|
||||
gapPx: frpPixelSize(fire.frp),
|
||||
accent: accentForSeverity(detectionColorStop(fire).name),
|
||||
title: t('layers.firms.card.ambient', { frp: formatFrp(fire.frp) }),
|
||||
title: t('▲ {frp} MW', { frp: formatFrp(fire.frp) }),
|
||||
details: [meta.join(' · ')],
|
||||
selected: false,
|
||||
priority: Number(fire.frp) || 0,
|
||||
@@ -408,16 +406,11 @@ export function createModel({
|
||||
|
||||
function buildCellCard(candidate, nowMs) {
|
||||
const cell = candidate.cell;
|
||||
const noun =
|
||||
cell.count === 1
|
||||
? t('layers.firms.card.fireNoun')
|
||||
: t('layers.firms.card.firesNoun');
|
||||
const parts = [
|
||||
t('layers.firms.card.maxFrp', { value: formatFrp(cell.maxFrp) }),
|
||||
];
|
||||
const noun = cell.count === 1 ? t('FIRE') : t('FIRES');
|
||||
const parts = [t('max {value} MW', { value: formatFrp(cell.maxFrp) })];
|
||||
if (cell.newestAcqMs > 0) {
|
||||
const age = formatAge(nowMs - cell.newestAcqMs);
|
||||
if (age) parts.push(t('layers.firms.card.newAge', { value: age }));
|
||||
if (age) parts.push(t('new {value}', { value: age }));
|
||||
}
|
||||
return {
|
||||
id: `cell:${cell.latCell ?? 'x'}:${cell.lonCell ?? 'x'}`,
|
||||
@@ -503,12 +496,11 @@ export function createModel({
|
||||
/** Millisecond delta → "<1m ago" / "Xm ago" / "Xh ago" (fresh-feed readout). */
|
||||
|
||||
function formatAgoMinutes(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0)
|
||||
return t('layers.meta.justNow');
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return t('just now');
|
||||
const minutes = Math.floor(deltaMs / 60000);
|
||||
if (minutes < 1) return t('layers.firms.underMinuteAgo');
|
||||
if (minutes < 90) return t('layers.meta.minutesAgo', { count: minutes });
|
||||
return t('layers.meta.hoursAgo', { count: Math.round(minutes / 60) });
|
||||
if (minutes < 1) return t('<1m ago');
|
||||
if (minutes < 90) return t('{count}m ago', { count: minutes });
|
||||
return t('{count}h ago', { count: Math.round(minutes / 60) });
|
||||
}
|
||||
|
||||
/** Normalized 0..1 confidence → low/nominal/high display bucket. */
|
||||
|
||||
@@ -60,24 +60,24 @@ export function createQueries({
|
||||
getStats() {
|
||||
const now = Date.now();
|
||||
const staleText = layerState._lastUpdate
|
||||
? t('layers.firms.staleCached', {
|
||||
? t('STALE · cached {age}', {
|
||||
age:
|
||||
components.model.formatAge(now - layerState._lastUpdate) || '<1h',
|
||||
})
|
||||
: t('layers.status.stale');
|
||||
: t('STALE');
|
||||
let loadingLabel = '';
|
||||
if (layerState._loading) {
|
||||
loadingLabel = layerState._fires.length
|
||||
? t('layers.meta.refreshing')
|
||||
: t('layers.meta.loading');
|
||||
? t('refreshing...')
|
||||
: t('loading...');
|
||||
} else if (layerState._keyRequired) {
|
||||
loadingLabel = t('layers.firms.keyRequired');
|
||||
loadingLabel = t('KEY REQUIRED');
|
||||
} else if (layerState._stale) {
|
||||
loadingLabel = staleText;
|
||||
} else if (layerState._error) {
|
||||
loadingLabel = layerState._error;
|
||||
} else if (layerState._lastUpdate) {
|
||||
loadingLabel = t('layers.firms.liveUpdated', {
|
||||
loadingLabel = t('LIVE · updated {age}', {
|
||||
age: components.model.formatAgoMinutes(now - layerState._lastUpdate),
|
||||
});
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export function createQueries({
|
||||
// the operator can take.
|
||||
keyRequired: layerState._keyRequired,
|
||||
error: layerState._keyRequired
|
||||
? t('layers.firms.keyRequired')
|
||||
? t('KEY REQUIRED')
|
||||
: layerState._stale
|
||||
? staleText
|
||||
: layerState._error,
|
||||
|
||||
@@ -756,7 +756,7 @@ export function createTracking({
|
||||
const spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : '';
|
||||
const stale =
|
||||
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
|
||||
? t('layers.status.stale')
|
||||
? t('STALE')
|
||||
: '';
|
||||
const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')];
|
||||
// Converted contacts report their class as TR-3B and nothing else — the
|
||||
|
||||
Vendored
+18
-17
@@ -5,7 +5,7 @@ import {
|
||||
DISTANCE_PREFILTER_MARGIN_M,
|
||||
ANCHOR_REFRESH_M,
|
||||
} from './policy.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { t, msg } from '../../i18n/index.js';
|
||||
|
||||
// installationFeedback.js composes its guidance inside the portable
|
||||
// node/browser graph (the server-side HUD summary reads the same module), so
|
||||
@@ -13,19 +13,20 @@ import { t } from '../../i18n/index.js';
|
||||
// boundary: map the closed message vocabulary to the catalog, passing any
|
||||
// live provider error through verbatim (docs/TRANSLATORS.md).
|
||||
const INSTALLATION_FEEDBACK_KEYS = Object.freeze({
|
||||
'Retrying mapped sites…': 'layers.installations.feedback.retrying',
|
||||
'Fetching mapped sites…': 'layers.installations.feedback.fetching',
|
||||
'Zoom in to search mapped installations':
|
||||
'layers.installations.feedback.zoomIn',
|
||||
'Showing cached mapped sites': 'layers.installations.feedback.cached',
|
||||
'Mapped sites not loaded': 'layers.installations.feedback.notLoaded',
|
||||
'Mapped sites loaded': 'layers.installations.feedback.loaded',
|
||||
'Overpass rate-limited': 'layers.installations.feedback.reason.rateLimited',
|
||||
'Overpass timed out': 'layers.installations.feedback.reason.timeout',
|
||||
'Overpass could not complete the query':
|
||||
'layers.installations.feedback.reason.queryFailed',
|
||||
'Overpass temporarily unavailable':
|
||||
'layers.installations.feedback.reason.unavailable',
|
||||
'Retrying mapped sites…': msg('Retrying mapped sites…'),
|
||||
'Fetching mapped sites…': msg('Fetching mapped sites…'),
|
||||
'Zoom in to search mapped installations': msg(
|
||||
'Zoom in to search mapped installations',
|
||||
),
|
||||
'Showing cached mapped sites': msg('Showing cached mapped sites'),
|
||||
'Mapped sites not loaded': msg('Mapped sites not loaded'),
|
||||
'Mapped sites loaded': msg('Mapped sites loaded'),
|
||||
'Overpass rate-limited': msg('Overpass rate-limited'),
|
||||
'Overpass timed out': msg('Overpass timed out'),
|
||||
'Overpass could not complete the query': msg(
|
||||
'Overpass could not complete the query',
|
||||
),
|
||||
'Overpass temporarily unavailable': msg('Overpass temporarily unavailable'),
|
||||
});
|
||||
|
||||
const INSTALLATION_RETRY_IN_RE = /^(.*) — retrying in (\d+)s$/;
|
||||
@@ -38,7 +39,7 @@ function localizeInstallationFeedback(message) {
|
||||
const retryIn = message.match(INSTALLATION_RETRY_IN_RE);
|
||||
if (retryIn) {
|
||||
const reasonKey = INSTALLATION_FEEDBACK_KEYS[retryIn[1]];
|
||||
return t('layers.installations.feedback.retryIn', {
|
||||
return t('{reason} — retrying in {seconds}s', {
|
||||
reason: reasonKey ? t(reasonKey) : retryIn[1],
|
||||
seconds: retryIn[2],
|
||||
});
|
||||
@@ -46,7 +47,7 @@ function localizeInstallationFeedback(message) {
|
||||
const pending = message.match(INSTALLATION_RETRY_PENDING_RE);
|
||||
if (pending) {
|
||||
const reasonKey = INSTALLATION_FEEDBACK_KEYS[pending[1]];
|
||||
return t('layers.installations.feedback.retryPending', {
|
||||
return t('{reason} — retry pending', {
|
||||
reason: reasonKey ? t(reasonKey) : pending[1],
|
||||
});
|
||||
}
|
||||
@@ -280,7 +281,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
}),
|
||||
),
|
||||
loadingLabel: layerState.loading
|
||||
? t('layers.installations.loading')
|
||||
? t('loading mapped installation context')
|
||||
: '',
|
||||
coverage: layerState.coverage,
|
||||
coverageLabel:
|
||||
|
||||
@@ -271,7 +271,7 @@ export function createRendering({
|
||||
entity.gevTrackedId = `installations:${record.id}`;
|
||||
entity.gevDisplayPosition = () => displayPosition;
|
||||
entity.gevLabelModel = {
|
||||
title: record.name || t('layers.installations.fallbackTitle'),
|
||||
title: record.name || t('MAPPED INSTALLATION'),
|
||||
details: [
|
||||
String(record.class || 'installation')
|
||||
.replaceAll('_', ' ')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { t, msg } from '../../i18n/index.js';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
@@ -120,10 +120,10 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
const details = selected
|
||||
? [
|
||||
siteName
|
||||
? t('layers.missions.launchSiteValue', {
|
||||
? t('LAUNCH SITE · {site}', {
|
||||
site: shortMissionLabel(siteName, 20).toUpperCase(),
|
||||
})
|
||||
: t('layers.missions.launchSite'),
|
||||
: t('LAUNCH SITE'),
|
||||
]
|
||||
: [];
|
||||
return {
|
||||
@@ -589,38 +589,37 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
const mission = shortMissionLabel(launch.name, 22).toUpperCase();
|
||||
const siteName = compactLaunchSiteName(launch.launchSite);
|
||||
const siteCallout = siteName
|
||||
? t('layers.missions.launchSiteValue', {
|
||||
? t('LAUNCH SITE · {site}', {
|
||||
site: shortMissionLabel(siteName, 20).toUpperCase(),
|
||||
})
|
||||
: t('layers.missions.launchSite');
|
||||
: t('LAUNCH SITE');
|
||||
let title = mission;
|
||||
let detail = siteCallout;
|
||||
if (mode === 'countdown') {
|
||||
title = t('layers.missions.replay.countdown', {
|
||||
title = t('T−{time} · {mission}', {
|
||||
time: String(state.countdownSeconds).padStart(2, '0'),
|
||||
mission,
|
||||
});
|
||||
detail = `${t('layers.missions.replay.standbyDetail')}\n${siteCallout}`;
|
||||
detail = `${t('LAUNCH STANDBY')}\n${siteCallout}`;
|
||||
} else if (mode === 'ascent') {
|
||||
title =
|
||||
state.elapsedSinceStart < 1
|
||||
? t('layers.missions.replay.liftoff', { mission })
|
||||
? t('LIFTOFF · {mission}', { mission })
|
||||
: t(
|
||||
launch.trajectory.length > 1
|
||||
? 'layers.missions.replay.ascentReplay'
|
||||
: 'layers.missions.replay.ascentEstimate',
|
||||
? msg('ASCENT REPLAY · {mission}')
|
||||
: msg('ASCENT ESTIMATE · {mission}'),
|
||||
{ mission },
|
||||
);
|
||||
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
|
||||
} else if (mode === 'recovery') {
|
||||
title = t('layers.missions.replay.recovery', { mission });
|
||||
title = t('STAGE RE-ENTRY / RECOVERY · {mission}', { mission });
|
||||
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
|
||||
} else if (mode === 'orbit') {
|
||||
title = t('layers.missions.replay.orbit', { mission });
|
||||
title = t('ORBIT REPLAY · {mission}', { mission });
|
||||
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
|
||||
}
|
||||
if (layerState._replayPaused)
|
||||
title = t('layers.missions.replay.pausedPrefix', { title });
|
||||
if (layerState._replayPaused) title = t('PAUSED · {title}', { title });
|
||||
const nextText = `${title}\n${detail}`;
|
||||
if (nextText !== layerState._replayVehicleOverlayText) {
|
||||
layerState._replayVehicleOverlayText = nextText;
|
||||
|
||||
@@ -220,12 +220,12 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || t('layers.missions.rows.unspecified'))}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || t('layers.missions.rows.unavailable'))}</td></tr>`;
|
||||
return `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || t('UNSPECIFIED'))}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || t('UNAVAILABLE'))}</td></tr>`;
|
||||
})
|
||||
: [];
|
||||
if (launch.payloads.length > 5) {
|
||||
payloadRows.push(
|
||||
`<tr><td colspan="3" class="mission-table-empty">${t('layers.missions.rows.additionalPayloads', { count: launch.payloads.length - 5 })}</td></tr>`,
|
||||
`<tr><td colspan="3" class="mission-table-empty">${t('+{count} additional payload records', { count: launch.payloads.length - 5 })}</td></tr>`,
|
||||
);
|
||||
}
|
||||
layerState._missionPanel.querySelector(
|
||||
@@ -233,7 +233,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
).innerHTML = missionTableRows(
|
||||
payloadRows,
|
||||
3,
|
||||
t('layers.missions.rows.classified'),
|
||||
t('PAYLOAD DATA UNAVAILABLE'),
|
||||
);
|
||||
const stageRows = launch.recoveryStages.map((stage) => {
|
||||
const endpoint = stage.endpoint;
|
||||
@@ -241,21 +241,21 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
stage.destination ||
|
||||
(endpoint?.accuracy === 'PAD / RTLS'
|
||||
? launch.launchSite
|
||||
: t('layers.missions.rows.unavailable'));
|
||||
: t('UNAVAILABLE'));
|
||||
const position = endpoint
|
||||
? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}`
|
||||
: stage.downrangeKm > 0
|
||||
? t('layers.missions.rows.kmDownrange', {
|
||||
? t('{value} KM DOWNRANGE', {
|
||||
value: stage.downrangeKm.toLocaleString(),
|
||||
})
|
||||
: t('layers.missions.rows.positionUnavailable');
|
||||
: t('POSITION UNAVAILABLE');
|
||||
const stageDetail = [
|
||||
Number.isFinite(stage.flightNumber)
|
||||
? t('layers.missions.rows.flightNumber', {
|
||||
? t('FLIGHT {count}', {
|
||||
count: stage.flightNumber,
|
||||
})
|
||||
: null,
|
||||
stage.reused ? t('layers.missions.rows.reused') : null,
|
||||
stage.reused ? t('REUSED') : null,
|
||||
stage.recoveryType,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -263,7 +263,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
return `<tr><td>${escapeMissionText(stage.name)}${stageDetail ? `<small>${escapeMissionText(stageDetail)}</small>` : ''}</td><td>${escapeMissionText(stage.status)}</td><td>${escapeMissionText(destination)}<small>${escapeMissionText(position)}</small></td></tr>`;
|
||||
});
|
||||
layerState._missionPanel.querySelector('[data-mission-stages]').innerHTML =
|
||||
missionTableRows(stageRows, 3, t('layers.missions.rows.noStageData'));
|
||||
missionTableRows(stageRows, 3, t('NO STAGE RE-ENTRY / RECOVERY DATA'));
|
||||
const stageSection = layerState._missionPanel.querySelector(
|
||||
'[data-mission-stages-section]',
|
||||
);
|
||||
@@ -291,7 +291,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
'[data-mission-roster-count]',
|
||||
);
|
||||
if (count)
|
||||
count.textContent = t('layers.missions.roster.count', {
|
||||
count.textContent = t('{count} / 30D', {
|
||||
count: layerState._launches.length,
|
||||
});
|
||||
if (!list) return;
|
||||
@@ -301,7 +301,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
layerState._launches,
|
||||
);
|
||||
if (!entries.length) {
|
||||
list.innerHTML = `<div class="space-mission-roster-empty">${t('layers.missions.roster.empty')}</div>`;
|
||||
list.innerHTML = `<div class="space-mission-roster-empty">${t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW')}</div>`;
|
||||
restoreMissionRosterFocus(
|
||||
list,
|
||||
focusSnapshot,
|
||||
@@ -314,15 +314,12 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
list.innerHTML = entries
|
||||
.map(({ launch, index }) => {
|
||||
const color = parts.model.missionMarkerColor(launch).toCssColorString();
|
||||
const date =
|
||||
launch.launchTime?.slice(0, 10) ||
|
||||
t('layers.missions.roster.dateUnavailable');
|
||||
const provider =
|
||||
launch.provider || t('layers.missions.roster.unspecifiedOperator');
|
||||
const date = launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE');
|
||||
const provider = launch.provider || t('UNSPECIFIED OPERATOR');
|
||||
const label = parts.overlays
|
||||
.shortMissionLabel(launch.name, 27)
|
||||
.toUpperCase();
|
||||
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="${escapeMissionText(t('layers.missions.roster.selectAria', { mission: label }))}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
|
||||
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="${escapeMissionText(t('Select {mission}', { mission: label }))}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
|
||||
})
|
||||
.join('');
|
||||
list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
|
||||
@@ -379,7 +376,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
setMissionPanelField(
|
||||
'[data-mission-distance]',
|
||||
Number.isFinite(altitudeM)
|
||||
? t('layers.missions.telemetry.km', {
|
||||
? t('{value} KM', {
|
||||
value: Math.max(0, altitudeM / 1000).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
}),
|
||||
@@ -392,7 +389,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
setMissionPanelField(
|
||||
'[data-mission-speed]',
|
||||
Number.isFinite(speedMps)
|
||||
? t('layers.missions.telemetry.kmPerSecond', {
|
||||
? t('{value} KM/S', {
|
||||
value: (speedMps / 1000).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
@@ -473,16 +470,16 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
layerState._missionPanel.className = 'context-space-mission-detail';
|
||||
layerState._missionPanel.setAttribute(
|
||||
'aria-label',
|
||||
t('layers.missions.panel.selectedHeader'),
|
||||
t('SELECTED SPACE MISSION'),
|
||||
);
|
||||
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>${t('layers.missions.panel.selectedHeader')}</span><button type="button" data-mission-close title="${t('layers.missions.panel.showAllTitle')}" aria-label="${t('layers.missions.panel.deselectAria')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('layers.missions.panel.mission')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('layers.missions.panel.status')}<b data-mission-status></b></span><span data-mission-field>${t('layers.missions.panel.launchSite')}<b data-mission-site></b></span><span data-mission-field>${t('layers.missions.panel.launchTime')}<b data-mission-time></b></span><span data-mission-field>${t('layers.missions.panel.orbit')}<b data-mission-orbit></b></span><span>${t('layers.missions.panel.ascentPath')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('layers.missions.panel.distance')}<b data-mission-distance></b></span><span data-mission-field>${t('layers.missions.panel.speed')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('layers.missions.panel.payload')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('layers.missions.panel.colName')}</th><th>${t('layers.missions.panel.colType')}</th><th>${t('layers.missions.panel.colDestination')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('layers.missions.panel.stagesSection')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('layers.missions.panel.colStage')}</th><th>${t('layers.missions.panel.colStatus')}</th><th>${t('layers.missions.panel.colFinalPosition')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('layers.missions.replay.speedLabel')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('layers.missions.replay.speedAria')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('layers.missions.panel.focus')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('layers.missions.replay.start')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('layers.missions.panel.prevTitle')}"><span aria-hidden="true">‹</span> ${t('layers.missions.panel.prev')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('layers.missions.panel.nextTitle')}">${t('layers.missions.panel.next')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('layers.missions.panel.showAll')}</button>`;
|
||||
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>${t('SELECTED SPACE MISSION')}</span><button type="button" data-mission-close title="${t('Show all missions')}" aria-label="${t('Deselect mission')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('MISSION')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('STATUS · ')}<b data-mission-status></b></span><span data-mission-field>${t('LAUNCH SITE · ')}<b data-mission-site></b></span><span data-mission-field>${t('LAUNCH TIME · ')}<b data-mission-time></b></span><span data-mission-field>${t('ORBIT · ')}<b data-mission-orbit></b></span><span>${t('ASCENT PATH · ')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('CURRENT DISTANCE FROM EARTH · ')}<b data-mission-distance></b></span><span data-mission-field>${t('SATELLITE SPEED · ')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('PAYLOAD')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('NAME')}</th><th>${t('TYPE')}</th><th>${t('DESTINATION')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('STAGE / RE-ENTRY / RECOVERY')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('STAGE')}</th><th>${t('STATUS')}</th><th>${t('FINAL POSITION')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('REPLAY SPEED')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('Replay speed multiplier')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('FOCUS')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('REPLAY ASCENT')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('Previous mission')}"><span aria-hidden="true">‹</span> ${t('PREV')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('Next mission')}">${t('NEXT')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('SHOW ALL / DESELECT')}</button>`;
|
||||
layerState._missionPanel
|
||||
.querySelector('.mission-action-row')
|
||||
.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
`<div class="mission-replay-transport" data-mission-replay-transport hidden>
|
||||
<button type="button" data-mission-replay-toggle title="${t('layers.missions.replay.pause')}" aria-label="${t('layers.missions.replay.pause')}">Ⅱ</button>
|
||||
<button type="button" class="cancel" data-mission-replay-cancel title="${t('layers.missions.replay.cancel')}" aria-label="${t('layers.missions.replay.cancel')}"><span aria-hidden="true">×</span></button>
|
||||
<button type="button" data-mission-replay-toggle title="${t('Pause replay')}" aria-label="${t('Pause replay')}">Ⅱ</button>
|
||||
<button type="button" class="cancel" data-mission-replay-cancel title="${t('Cancel replay')}" aria-label="${t('Cancel replay')}"><span aria-hidden="true">×</span></button>
|
||||
</div>`,
|
||||
);
|
||||
host.appendChild(layerState._missionPanel);
|
||||
|
||||
@@ -522,7 +522,7 @@ export function createPaths({ state: layerState, services, parts, source }) {
|
||||
return {
|
||||
lat: Cesium.Math.toDegrees(lat),
|
||||
lon: Cesium.Math.toDegrees(Cesium.Math.negativePiToPi(lon)),
|
||||
accuracy: t('layers.missions.estDownrange'),
|
||||
accuracy: t('EST. DOWNRANGE'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -77,14 +77,14 @@ export function createPolicyHelpers({
|
||||
: 0;
|
||||
return {
|
||||
orbit: orbitName
|
||||
? `${orbitAllowed ? '' : t('layers.missions.rows.plannedPrefix')}${orbitName}`
|
||||
? `${orbitAllowed ? '' : t('PLANNED · ')}${orbitName}`
|
||||
: null,
|
||||
ascent:
|
||||
suppliedTrajectoryPoints > 1
|
||||
? t('layers.missions.rows.suppliedTrajectory')
|
||||
? t('SUPPLIED TRAJECTORY POINTS')
|
||||
: replayAvailable
|
||||
? t('layers.missions.rows.reconstructedEstimate')
|
||||
: t('layers.missions.rows.unavailable'),
|
||||
? t('RECONSTRUCTED ESTIMATE')
|
||||
: t('UNAVAILABLE'),
|
||||
replayAvailable: Boolean(replayAvailable),
|
||||
};
|
||||
}
|
||||
@@ -198,7 +198,7 @@ export function createPolicyHelpers({
|
||||
function formatMissionEventTime(launchTime) {
|
||||
const date = new Date(launchTime);
|
||||
if (!launchTime || !Number.isFinite(date.getTime()))
|
||||
return t('layers.missions.rows.unavailable');
|
||||
return t('UNAVAILABLE');
|
||||
return `${date.toISOString().slice(0, 10)}\n${date.toISOString().slice(11, 19)} UTC`;
|
||||
}
|
||||
|
||||
|
||||
@@ -201,10 +201,10 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
if (speedControl) speedControl.hidden = !replayAvailable;
|
||||
button.hidden = active || !replayAvailable;
|
||||
button.disabled = !replayAvailable;
|
||||
button.textContent = t('layers.missions.replay.start');
|
||||
button.textContent = t('REPLAY ASCENT');
|
||||
button.classList.remove('active');
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
button.title = t('layers.missions.replay.startTitle');
|
||||
button.title = t('Replay the estimated ascent with a following camera');
|
||||
if (transport) {
|
||||
transport.hidden = !active;
|
||||
transport.classList.toggle(
|
||||
@@ -219,13 +219,11 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
toggleButton.textContent = layerState._replayPaused ? '▶' : 'Ⅱ';
|
||||
toggleButton.setAttribute(
|
||||
'aria-label',
|
||||
layerState._replayPaused
|
||||
? t('layers.missions.replay.resume')
|
||||
: t('layers.missions.replay.pause'),
|
||||
layerState._replayPaused ? t('Resume replay') : t('Pause replay'),
|
||||
);
|
||||
toggleButton.title = layerState._replayPaused
|
||||
? t('layers.missions.replay.resume')
|
||||
: t('layers.missions.replay.pause');
|
||||
? t('Resume replay')
|
||||
: t('Pause replay');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,19 +234,19 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
);
|
||||
if (!transport || !layerState._replayCameraLaunchId) return;
|
||||
const phase = state.countdownActive
|
||||
? t('layers.missions.replay.phaseCountdown', {
|
||||
? t('T minus {seconds}', {
|
||||
seconds: state.countdownSeconds,
|
||||
})
|
||||
: state.preCountdownActive
|
||||
? t('layers.missions.replay.phasePreparing')
|
||||
? t('Preparing launch site')
|
||||
: state.elapsedSinceStart < 1
|
||||
? t('layers.missions.replay.phaseLiftoff')
|
||||
? t('Liftoff')
|
||||
: state.ascending
|
||||
? t('layers.missions.replay.phaseAscent')
|
||||
: t('layers.missions.replay.phaseOrbit');
|
||||
? t('Ascent replay')
|
||||
: t('Orbit replay');
|
||||
transport.setAttribute(
|
||||
'aria-label',
|
||||
`${phase}${layerState._replayPaused ? t('layers.missions.replay.pausedSuffix') : ''}`,
|
||||
`${phase}${layerState._replayPaused ? t(', paused') : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ export function createQueries({
|
||||
*/
|
||||
|
||||
function _formatAltitude(altitudeFt) {
|
||||
if (!Number.isFinite(altitudeFt)) return t('layers.readout.altUnknown');
|
||||
if (!Number.isFinite(altitudeFt)) return t('Alt unknown');
|
||||
return `${Math.round(altitudeFt)} ft`;
|
||||
}
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ export function createTracking({
|
||||
function _buildTrackedLabel(info, icao24) {
|
||||
const stale =
|
||||
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
|
||||
? ` · ${t('layers.status.stale')}`
|
||||
? ` · ${t('STALE')}`
|
||||
: '';
|
||||
const callsign =
|
||||
(parts.queries._toCleanText(info?.callsign) ||
|
||||
@@ -230,14 +230,12 @@ export function createTracking({
|
||||
// what the Easter egg replaces the real type with.
|
||||
const type = tr3bTypeLabel(
|
||||
icao24,
|
||||
parts.queries._toCleanText(info?.type) || t('layers.readout.typeUnknown'),
|
||||
parts.queries._toCleanText(info?.type) || t('Type unknown'),
|
||||
);
|
||||
const registration =
|
||||
parts.queries._toCleanText(info?.registration) ||
|
||||
t('layers.readout.regUnknown');
|
||||
parts.queries._toCleanText(info?.registration) || t('Reg unknown');
|
||||
const operator =
|
||||
parts.queries._toCleanText(info?.operator) ||
|
||||
t('layers.readout.operatorUnknown');
|
||||
parts.queries._toCleanText(info?.operator) || t('Operator unknown');
|
||||
const altitude = parts.queries._formatAltitude(info?.altitudeFt);
|
||||
const speedKt = info?.speedMps ? Math.round(info.speedMps * 1.944) : null;
|
||||
const tail = speedKt ? `${altitude} · ${speedKt} kt` : altitude;
|
||||
|
||||
@@ -70,20 +70,20 @@ export function createCategories({
|
||||
function buildRadioCategories(stations) {
|
||||
const rows = Array.isArray(stations) ? stations : [];
|
||||
const categories = [
|
||||
{ id: 'all', label: t('layers.radio.filterAll') },
|
||||
{ id: 'news', label: t('layers.radio.category.news') },
|
||||
{ id: 'talk', label: t('layers.radio.category.talk') },
|
||||
{ id: 'weather', label: t('layers.radio.category.weather') },
|
||||
{ id: 'public-safety', label: t('layers.radio.category.publicSafety') },
|
||||
{ id: 'all', label: t('All') },
|
||||
{ id: 'news', label: t('News') },
|
||||
{ id: 'talk', label: t('Talk') },
|
||||
{ id: 'weather', label: t('Weather / Emergency') },
|
||||
{ id: 'public-safety', label: t('Public Safety') },
|
||||
{
|
||||
id: 'aviation-marine',
|
||||
label: t('layers.radio.category.aviationMarine'),
|
||||
label: t('Aviation / Marine'),
|
||||
},
|
||||
{
|
||||
id: 'traffic-transit',
|
||||
label: t('layers.radio.category.trafficTransit'),
|
||||
label: t('Traffic / Transit'),
|
||||
},
|
||||
{ id: 'music', label: t('layers.radio.category.music') },
|
||||
{ id: 'music', label: t('Music') },
|
||||
];
|
||||
|
||||
for (const [genre, label] of MUSIC_GENRES) {
|
||||
@@ -92,7 +92,7 @@ export function createCategories({
|
||||
categories.push({ id, label });
|
||||
}
|
||||
}
|
||||
categories.push({ id: 'other', label: t('layers.radio.category.other') });
|
||||
categories.push({ id: 'other', label: t('Other') });
|
||||
return categories.map((category) => ({
|
||||
...category,
|
||||
color: parts.model.radioCategoryColor(category.id),
|
||||
|
||||
Vendored
+13
-8
@@ -494,22 +494,27 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
const active =
|
||||
layerState._params.catalog === 'dense' &&
|
||||
layerState._denseStatus === 'ready';
|
||||
let title = t('layers.satellites.denseTitleAdd');
|
||||
if (loading) title = t('layers.satellites.denseTitleLoading');
|
||||
let title = t(
|
||||
'Add the full Starlink broadband shell (thousands of extra points)',
|
||||
);
|
||||
if (loading) title = t('Loading the Starlink shell…');
|
||||
else if (failed)
|
||||
title = t('layers.satellites.denseTitleFailed', {
|
||||
detail: layerState._denseError || t('layers.satellites.loadFailed'),
|
||||
title = t('Starlink {detail} — click to retry', {
|
||||
detail: layerState._denseError || t('load failed'),
|
||||
});
|
||||
else if (active) title = t('layers.satellites.denseTitleActive');
|
||||
else if (active)
|
||||
title = t(
|
||||
'Showing the full Starlink shell — click for the core catalog only',
|
||||
);
|
||||
return {
|
||||
chips: [
|
||||
{
|
||||
id: 'catalog',
|
||||
label: loading
|
||||
? t('layers.satellites.denseChipLoading')
|
||||
? t('DENSE ···')
|
||||
: failed
|
||||
? t('layers.satellites.denseChipFailed')
|
||||
: t('layers.satellites.denseChip'),
|
||||
? t('DENSE ✕')
|
||||
: t('DENSE'),
|
||||
active,
|
||||
busy: loading,
|
||||
disabled: loading,
|
||||
|
||||
@@ -12,7 +12,7 @@ export function createIngestion({ state, parts, source }) {
|
||||
|
||||
state._loading = true;
|
||||
state._error = null;
|
||||
state._loadingLabel = t('layers.meta.loading');
|
||||
state._loadingLabel = t('loading...');
|
||||
// Ownership token (the militaryAwareness activationId pattern): a
|
||||
// disable/abort followed by a fresh enable starts a NEWER load while this
|
||||
// one is still settling. The stale load must bail after every await and
|
||||
|
||||
+24
-16
@@ -261,12 +261,12 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
mode,
|
||||
error: null,
|
||||
loadingLabel: fetching
|
||||
? t('layers.traffic.syncingRoads', { source: roadSource })
|
||||
? t('Syncing flow · Roads: {source}', { source: roadSource })
|
||||
: coveragePct > 0
|
||||
? t('layers.traffic.liveRoadsFlowHidden', {
|
||||
? t('LIVE · Roads: {source} · Roads without flow hidden', {
|
||||
source: roadSource,
|
||||
})
|
||||
: t('layers.traffic.liveRoadsNoFlowInView', {
|
||||
: t('LIVE · Roads: {source} · No flow roads in view', {
|
||||
source: roadSource,
|
||||
}),
|
||||
};
|
||||
@@ -276,13 +276,13 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
mode,
|
||||
error: null,
|
||||
loadingLabel: fetching
|
||||
? t('layers.traffic.syncingRoads', { source: roadSource })
|
||||
? t('Syncing flow · Roads: {source}', { source: roadSource })
|
||||
: coveragePct > 0
|
||||
? t('layers.traffic.roadsFlowLive', {
|
||||
? t('LIVE · Roads: {source} · Flow {percent}%', {
|
||||
source: roadSource,
|
||||
percent: coveragePct,
|
||||
})
|
||||
: t('layers.traffic.roadsFlowSimulated', {
|
||||
: t('SIMULATED · Roads: {source} · Flow {percent}%', {
|
||||
source: roadSource,
|
||||
percent: coveragePct,
|
||||
}),
|
||||
@@ -293,16 +293,24 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
mode,
|
||||
error: null,
|
||||
loadingLabel: fetching
|
||||
? t('layers.traffic.loadingSyncing')
|
||||
? t(
|
||||
'Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated',
|
||||
)
|
||||
: coveragePct > 0
|
||||
? coveragePct < 100
|
||||
? t('layers.traffic.liveCoverageUnmatched', {
|
||||
percent: coveragePct,
|
||||
})
|
||||
: t('layers.traffic.liveCoverage', {
|
||||
percent: coveragePct,
|
||||
})
|
||||
: t('layers.traffic.simulatedNoMatches'),
|
||||
? t(
|
||||
'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated',
|
||||
{
|
||||
percent: coveragePct,
|
||||
},
|
||||
)
|
||||
: t(
|
||||
'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov',
|
||||
{
|
||||
percent: coveragePct,
|
||||
},
|
||||
)
|
||||
: t('SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)'),
|
||||
};
|
||||
}
|
||||
// Keyless simulation — one terse line that names the mode and the remedy
|
||||
@@ -312,8 +320,8 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
mode,
|
||||
error: null,
|
||||
loadingLabel: statusUnavailable
|
||||
? t('layers.traffic.simUnavailable')
|
||||
: t('layers.traffic.simKeyless'),
|
||||
? t('SIMULATED — traffic service unreachable')
|
||||
: t('SIMULATED — add TomTom key for live'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+11
-11
@@ -21,13 +21,13 @@ export function createCards({
|
||||
const stale = (record.missedRefreshes || 0) > 0;
|
||||
el.classList.add('active');
|
||||
el.textContent = [
|
||||
t('layers.vessel.hudName', { name: trimHudValue(record.name, 32) }),
|
||||
t('layers.vessel.hudTypeLine', {
|
||||
type: trimHudValue(record.type || t('layers.vessel.fallback'), 24),
|
||||
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
|
||||
t('{type} SPD: {speed} HDG: {heading}', {
|
||||
type: trimHudValue(record.type || t('VESSEL'), 24),
|
||||
speed: formatSpeed(record.speed),
|
||||
heading: formatHeading(record.heading ?? record.course),
|
||||
}),
|
||||
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('layers.status.stale')}` : ''}`,
|
||||
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function createCards({
|
||||
const el = document.getElementById('hud-ais-vessel');
|
||||
if (!el) return;
|
||||
el.classList.remove('active');
|
||||
el.textContent = t('layers.vessel.hudIdle');
|
||||
el.textContent = t('AIS: --');
|
||||
}
|
||||
|
||||
function trimHudValue(value, maxLength) {
|
||||
@@ -90,7 +90,7 @@ export function createCards({
|
||||
const direction = record.heading ?? record.course;
|
||||
const details = [
|
||||
[
|
||||
vesselTypeShort(record) || t('layers.vessel.fallback'),
|
||||
vesselTypeShort(record) || t('VESSEL'),
|
||||
formatSpeed(record.speed),
|
||||
Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°',
|
||||
].join(' · '),
|
||||
@@ -99,7 +99,7 @@ export function createCards({
|
||||
if (destination) details.push(`→ ${trimHudValue(destination, 24)}`);
|
||||
const stale = (record.missedRefreshes || 0) > 0;
|
||||
details.push(
|
||||
`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ` · ${t('layers.status.stale')}` : ''}`,
|
||||
`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
|
||||
);
|
||||
return {
|
||||
id: vesselOverlayEntryId(record),
|
||||
@@ -156,7 +156,7 @@ export function createCards({
|
||||
function displayVesselName(record) {
|
||||
const name = String(record.name || '').trim();
|
||||
if (name && name !== 'VESSEL' && name !== record.mmsi) return name;
|
||||
return record.mmsi ? `MMSI ${record.mmsi}` : t('layers.vessel.fallback');
|
||||
return record.mmsi ? `MMSI ${record.mmsi}` : t('VESSEL');
|
||||
}
|
||||
|
||||
function formatSpeed(speed) {
|
||||
@@ -168,10 +168,10 @@ export function createCards({
|
||||
}
|
||||
|
||||
function formatPositionTime(record) {
|
||||
if (!record.lastPositionUtc) return t('layers.vessel.posLive');
|
||||
if (!record.lastPositionUtc) return t('POS: LIVE');
|
||||
const date = new Date(record.lastPositionUtc);
|
||||
if (Number.isNaN(date.getTime())) return t('layers.vessel.posLive');
|
||||
return t('layers.vessel.posAt', { time: date.toISOString().slice(11, 19) });
|
||||
if (Number.isNaN(date.getTime())) return t('POS: LIVE');
|
||||
return t('POS: {time}Z', { time: date.toISOString().slice(11, 19) });
|
||||
}
|
||||
return {
|
||||
updateSelectedVesselHud,
|
||||
|
||||
@@ -38,7 +38,7 @@ export function createLifecycle({
|
||||
state.feed.firstConnectStartedAt = startedAt;
|
||||
state.feed.firstConnectDeadline = startedAt + AIS_FIRST_CONNECT_GRACE_MS;
|
||||
state.feed.error = null;
|
||||
state.feed.loadingLabel = t('layers.vessel.awaitingFirstPosition');
|
||||
state.feed.loadingLabel = t('awaiting first AIS position…');
|
||||
scheduleFirstConnectExpiry(sessionId, AIS_FIRST_CONNECT_GRACE_MS);
|
||||
}
|
||||
|
||||
@@ -60,8 +60,8 @@ export function createLifecycle({
|
||||
state.feed.firstConnectPhase = 'unavailable';
|
||||
state.feed.loadingLabel = '';
|
||||
state.feed.error = state.feed.lastMessageAt
|
||||
? t('layers.vessel.awaitingPositions')
|
||||
: t('layers.vessel.awaitingFirstMessage');
|
||||
? t('awaiting usable AIS positions…')
|
||||
: t('awaiting first AIS message…');
|
||||
state.feed.stale = state.feed.count > 0;
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { t, msg } from '../../i18n/index.js';
|
||||
|
||||
// The vessels ingestion owner is a portable module graph (it also runs in the
|
||||
// server-side source builds), so it writes stable English feed labels into
|
||||
// state. Localize those known labels here, at the presentation read; anything
|
||||
// else (a bespoke manager label) passes through verbatim.
|
||||
const FEED_STATE_LABEL_KEYS = Object.freeze({
|
||||
'refreshing...': 'layers.meta.refreshing',
|
||||
'loading...': 'layers.meta.loading',
|
||||
'awaiting first AIS position…': 'layers.vessel.awaitingFirstPosition',
|
||||
'refreshing...': msg('refreshing...'),
|
||||
'loading...': msg('loading...'),
|
||||
'awaiting first AIS position…': msg('awaiting first AIS position…'),
|
||||
});
|
||||
|
||||
function localizeFeedLabel(raw) {
|
||||
@@ -98,8 +98,8 @@ export function createQueries({
|
||||
if (acceptedRowCount > 0) return null; // accepted rows may be stale while reconnecting, but remain usable
|
||||
if (AIS_HEALTHY_STATUSES.has(status)) {
|
||||
return payload?.lastMessageAt
|
||||
? t('layers.vessel.awaitingPositions')
|
||||
: t('layers.vessel.awaitingFirstMessage');
|
||||
? t('awaiting usable AIS positions…')
|
||||
: t('awaiting first AIS message…');
|
||||
}
|
||||
if (!status) return null;
|
||||
const detail =
|
||||
@@ -158,9 +158,7 @@ export function createQueries({
|
||||
error: emptyCoverage
|
||||
? null
|
||||
: deriveAisFeedError(payload, acceptedRowCount) ||
|
||||
(acceptedRowCount === 0
|
||||
? t('layers.vessel.awaitingPositions')
|
||||
: null),
|
||||
(acceptedRowCount === 0 ? t('awaiting usable AIS positions…') : null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -532,7 +530,7 @@ export function createQueries({
|
||||
lastUpdate: state.feed.lastUpdate,
|
||||
loading: state.feed.loading || waitingForFirstPosition,
|
||||
loadingLabel: waitingForFirstPosition
|
||||
? t('layers.vessel.awaitingFirstPosition')
|
||||
? t('awaiting first AIS position…')
|
||||
: localizeFeedLabel(state.feed.loadingLabel),
|
||||
error: state.feed.error,
|
||||
stale: state.feed.stale,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosu
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { t } from './i18n/index.js';
|
||||
import { msg, t } from './i18n/index.js';
|
||||
|
||||
// Exercise the installed event routes and central close method, without WebGL.
|
||||
const source = readShellSource();
|
||||
@@ -108,9 +108,9 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
|
||||
window.clearTimeout = (id) => timers.delete(id);
|
||||
window.performance = { now: () => now };
|
||||
document.defaultView = window;
|
||||
const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame', 't',
|
||||
const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame', 't', 'msg',
|
||||
`return ({${shellMethod('_initAutoHoverPanel').toString()},\n${shellMethod('_collapsePanelOnEscape').toString()},\n${shellMethod('setPanelCollapsed').toString()},\n${shellMethod('_syncPanelCollapseButton').toString()}});`)(
|
||||
createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {}, t,
|
||||
createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {}, t, msg,
|
||||
);
|
||||
const saves = [];
|
||||
const claims = [];
|
||||
|
||||
@@ -41,11 +41,11 @@ export function mapStackChipModel(stack, activeId) {
|
||||
// Stack labels are provider/dataset names (keep-English); the availability
|
||||
// sentence around them is copy and localizes here at the model boundary.
|
||||
const fallbackReason = requiresIon
|
||||
? t('setup.keySetup.requirement', {
|
||||
? t('Needs {envVars} — add it in Provider Settings', {
|
||||
envVars: keySetupRequirementEnvVars('cesium-ion'),
|
||||
})
|
||||
: t('setup.mapStack.unavailableReason', {
|
||||
label: label || t('setup.mapStack.fallbackName'),
|
||||
: t('{label} is unavailable', {
|
||||
label: label || t('This map stack'),
|
||||
});
|
||||
const unavailableHint = available
|
||||
? ''
|
||||
@@ -126,7 +126,7 @@ export function renderMapStackChips(
|
||||
if (!model.available) {
|
||||
chip.setAttribute(
|
||||
'aria-label',
|
||||
t('setup.mapStack.unavailableAriaLabel', {
|
||||
t('{label} unavailable: {hint}', {
|
||||
label: model.label,
|
||||
hint: model.unavailableHint,
|
||||
}),
|
||||
|
||||
@@ -16,5 +16,5 @@ export function photorealUnavailableReason(hasCredentials) {
|
||||
if (hasCredentials)
|
||||
return "Google 3D tiles unavailable — check the key's API restrictions, quota, or network";
|
||||
const envVars = keySetupRequirementEnvVars('google-maps');
|
||||
return `${t('setup.keySetup.requirement', { envVars })} — or a Cesium ion token for the ion-hosted route`;
|
||||
return `${t('Needs {envVars} — add it in Provider Settings', { envVars })} — or a Cesium ion token for the ion-hosted route`;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function createDefaultMapSources({
|
||||
descriptor,
|
||||
available: !descriptor.requiresIon || hasIon,
|
||||
unavailableReason: descriptor.requiresIon
|
||||
? t('setup.keySetup.requirement', {
|
||||
? t('Needs {envVars} — add it in Provider Settings', {
|
||||
envVars: keySetupRequirementEnvVars('cesium-ion'),
|
||||
})
|
||||
: null,
|
||||
|
||||
+20
-18
@@ -26,7 +26,7 @@ import {
|
||||
getSceneAppendRecipeById,
|
||||
} from './recipes.js';
|
||||
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { createSceneDataPacks } from './dataPacks/controller.js';
|
||||
import { createSceneInteractions } from './interactions.js';
|
||||
import { createSceneSharing } from './sharing.js';
|
||||
@@ -67,11 +67,11 @@ import {
|
||||
// boundary + persisted data); only the presentation of an UNRENAMED built-in
|
||||
// localizes, so an operator's renamed scene is never masked by the catalog.
|
||||
const RECIPE_TITLE_KEYS = Object.freeze({
|
||||
'flights-radar': 'setup.scenes.recipe.flightsRadar',
|
||||
'orbital-watch': 'setup.scenes.recipe.orbitalWatch',
|
||||
'thermal-threats': 'setup.scenes.recipe.thermalThreats',
|
||||
'city-overload': 'setup.scenes.recipe.cityOverload',
|
||||
'omniscience-pullback': 'setup.scenes.recipe.omnisciencePullback',
|
||||
'flights-radar': msg('Global Flights Radar'),
|
||||
'orbital-watch': msg('Orbital Watch'),
|
||||
'thermal-threats': msg('Thermal Threat Board'),
|
||||
'city-overload': msg('City Overload'),
|
||||
'omniscience-pullback': msg('Omniscience Pullback'),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -198,7 +198,7 @@ export class SceneDirector {
|
||||
this._presentation = {
|
||||
status: this._storageReadError
|
||||
? 'Saved project could not be read; storage preserved. Import a valid file to resume saving.'
|
||||
: t('setup.scenes.statusReady'),
|
||||
: t('Ready'),
|
||||
progress: 0,
|
||||
runtime: '',
|
||||
playbackActive: false,
|
||||
@@ -396,7 +396,7 @@ export class SceneDirector {
|
||||
|
||||
_shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) {
|
||||
if (type === 'shot-loaded')
|
||||
this._presentation.status = t('setup.scenes.status.loaded', {
|
||||
this._presentation.status = t('Loaded: {scene} / {shot}', {
|
||||
scene: scene.title,
|
||||
shot: shot.title,
|
||||
});
|
||||
@@ -954,14 +954,14 @@ export class SceneDirector {
|
||||
|
||||
const camera = this.styleManager.getCameraState();
|
||||
if (!camera) {
|
||||
this._updateStatus(t('setup.scenes.status.captureCameraNotReady'));
|
||||
this._updateStatus(t('Cannot capture shot: camera not ready'));
|
||||
return;
|
||||
}
|
||||
|
||||
const shot = normalizeShot(
|
||||
{
|
||||
id: uid('shot'),
|
||||
title: t('setup.scenes.status.shotTitleDefault', {
|
||||
title: t('Shot {n}', {
|
||||
n: scene.shots.length + 1,
|
||||
}),
|
||||
durationSec: DEFAULT_SHOT_DURATION_SEC,
|
||||
@@ -978,7 +978,7 @@ export class SceneDirector {
|
||||
this._saveProject();
|
||||
this._shotOutcome('shot-captured', scene, shot);
|
||||
this._updateStatus(
|
||||
t('setup.scenes.status.captured', {
|
||||
t('Captured: {scene} / {shot}', {
|
||||
scene: scene.title,
|
||||
shot: shot.title,
|
||||
}),
|
||||
@@ -995,7 +995,7 @@ export class SceneDirector {
|
||||
|
||||
const shot = scene.shots.find((item) => item.id === this._selectedShotId);
|
||||
if (!shot) {
|
||||
this._updateStatus(t('setup.scenes.status.selectShotFirst'));
|
||||
this._updateStatus(t('Select a shot first'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1011,7 +1011,7 @@ export class SceneDirector {
|
||||
this._saveProject();
|
||||
this._shotOutcome('shot-updated', scene, shot);
|
||||
this._updateStatus(
|
||||
t('setup.scenes.status.updated', {
|
||||
t('Updated: {scene} / {shot}', {
|
||||
scene: scene.title,
|
||||
shot: shot.title,
|
||||
}),
|
||||
@@ -1610,7 +1610,7 @@ export class SceneDirector {
|
||||
this._claimingCamera = false;
|
||||
}
|
||||
if (claimed === false) {
|
||||
this._updateStatus(t('setup.scenes.status.cameraUnavailable'));
|
||||
this._updateStatus(t('Camera unavailable — exit cockpit first'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -1745,7 +1745,7 @@ export class SceneDirector {
|
||||
if (!queue.length) return { started: false, reason: 'scene-complete' };
|
||||
}
|
||||
if (!queue.length) {
|
||||
this._updateStatus(t('setup.scenes.status.noShotsToRun'));
|
||||
this._updateStatus(t('No shots to run'));
|
||||
return { started: false, reason: 'no-shots' };
|
||||
}
|
||||
|
||||
@@ -1829,7 +1829,7 @@ export class SceneDirector {
|
||||
});
|
||||
} catch (error) {
|
||||
this._updateStatus(
|
||||
t('setup.scenes.status.runError', {
|
||||
t('Error: {message}', {
|
||||
message: error.message || 'run failed',
|
||||
}),
|
||||
);
|
||||
@@ -2006,7 +2006,7 @@ export class SceneDirector {
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
this._presentation.status = t('setup.scenes.status.projectExported');
|
||||
this._presentation.status = t('Project exported');
|
||||
this._publish({ type: 'project-exported', project: this._project });
|
||||
}
|
||||
|
||||
@@ -2268,7 +2268,9 @@ export class SceneDirector {
|
||||
`[Scenes] Could not exit ${mode}:`,
|
||||
result.error || 'unknown reason',
|
||||
);
|
||||
this._updateStatus(t('setup.scenes.status.contextExitFailed', { mode }));
|
||||
this._updateStatus(
|
||||
t('Could not exit {mode} — scene layers may be refused', { mode }),
|
||||
);
|
||||
this._logEvent('context_mode_exit_failed', {
|
||||
mode,
|
||||
error: result.error || null,
|
||||
|
||||
@@ -16,7 +16,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
||||
director._renderSceneSelect();
|
||||
director._renderShotList();
|
||||
director._updateStatus(
|
||||
t('setup.scenes.status.runningShot', {
|
||||
t('Running {index}/{total}: {scene} / {shot}', {
|
||||
index: index + 1,
|
||||
total,
|
||||
scene: scene.title,
|
||||
@@ -78,7 +78,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
||||
},
|
||||
complete() {
|
||||
director._setProgress(1);
|
||||
director._updateStatus(t('setup.scenes.status.runComplete'));
|
||||
director._updateStatus(t('Scene run complete'));
|
||||
director._logEvent('scene_run_complete', {});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,8 +11,6 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
|
||||
plugins: [plugin],
|
||||
googleApiKey: 'browser-fixture',
|
||||
cesiumToken: 'ion-fixture',
|
||||
defaultLocale: 'fr',
|
||||
secondaryLocale: 'en',
|
||||
});
|
||||
// The Host check runs before every other plugin's middleware.
|
||||
assert.equal(config.plugins[0].name, 'host-check');
|
||||
@@ -40,9 +38,6 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
|
||||
assert.deepEqual(config.define, {
|
||||
'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"',
|
||||
'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"',
|
||||
// Locale pair (i18n): threaded like the other explicit browser inputs.
|
||||
'import.meta.env.GEV_DEFAULT_LOCALE': '"fr"',
|
||||
'import.meta.env.GEV_SECONDARY_LOCALE': '"en"',
|
||||
'import.meta.env.MAPILLARY_CLIENT_TOKEN': '""',
|
||||
});
|
||||
assert.equal(
|
||||
|
||||
+22
-30
@@ -38,7 +38,7 @@ import { ShellFeedback } from './shellFeedback.js';
|
||||
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
|
||||
|
||||
import { getLocale, persistLocaleAndReload, t } from '../i18n/index.js';
|
||||
import { availableLocales } from '../i18n/locale.js';
|
||||
import { LOCALES, LOCALE_NAMES } from '../i18n/locale.js';
|
||||
|
||||
/**
|
||||
* Central UI orchestrator for the God's Eye View application.
|
||||
@@ -706,15 +706,9 @@ export class StyleManager extends ShellFacade {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the command-dock locale switch (runtime-built since the
|
||||
* configurable-pair stage): one button per locale availableLocales()
|
||||
* yields — the configured GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE pair,
|
||||
* already deduped against the always-shipped English fallback. Labels are
|
||||
* uppercase locale codes (EN/ES/FR); aria-labels come from
|
||||
* shell.locale.<code>.ariaLabel in the active catalog. Pressed state is
|
||||
* synced from the resolved locale at boot; a click persists the choice and
|
||||
* reloads the page, so no live re-apply of document translations is needed
|
||||
* here (persistLocaleAndReload strips ?lang and keeps the hash).
|
||||
* Renders the command-dock locale switch: one button per shipped locale,
|
||||
* labelled with its code and named in its own language. A click stores the
|
||||
* choice and reloads, so nothing re-renders live.
|
||||
* @returns {void}
|
||||
*/
|
||||
_initLocaleSelector() {
|
||||
@@ -723,13 +717,14 @@ export class StyleManager extends ShellFacade {
|
||||
);
|
||||
if (!container) return;
|
||||
const active = getLocale();
|
||||
for (const locale of availableLocales()) {
|
||||
for (const locale of LOCALES) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'dock-locale-btn';
|
||||
button.dataset.locale = locale;
|
||||
button.textContent = locale.toUpperCase();
|
||||
button.setAttribute('aria-label', t(`shell.locale.${locale}.ariaLabel`));
|
||||
button.setAttribute('aria-label', LOCALE_NAMES[locale]);
|
||||
button.lang = locale;
|
||||
const isActive = locale === active;
|
||||
button.classList.toggle('active', isActive);
|
||||
button.setAttribute('aria-pressed', String(isActive));
|
||||
@@ -770,15 +765,15 @@ export class StyleManager extends ShellFacade {
|
||||
_applyRuntimeStaticHeaderText() {
|
||||
this._setHeaderText(
|
||||
document.querySelector('#control-panel .panel-title'),
|
||||
t('cockpit.presets.title'),
|
||||
t('VISUAL PRESETS'),
|
||||
);
|
||||
this._setHeaderText(
|
||||
document.querySelector('#location-bar .location-toolbar-label'),
|
||||
t('cockpit.location.toolbarLabel'),
|
||||
t('LOCATION'),
|
||||
);
|
||||
this._setHeaderText(
|
||||
document.querySelector('#pp-toggles .pp-header-label'),
|
||||
t('cockpit.display.title'),
|
||||
t('DISPLAY'),
|
||||
);
|
||||
// The cockpit context kicker ('CONTACT') is visible on the map itself, and
|
||||
// the briefing kicker shares its element with the live-dot <i>; both are
|
||||
@@ -786,7 +781,7 @@ export class StyleManager extends ShellFacade {
|
||||
// text node keeps renderBriefPage's leading-space convention.
|
||||
this._setHeaderText(
|
||||
document.querySelector('.cockpit-context-kicker'),
|
||||
t('cockpit.context.kicker'),
|
||||
t('CONTACT'),
|
||||
);
|
||||
// The phase-2 key-only standby description: one span holds BOTH mode
|
||||
// descriptions split by a literal <br>, so no attribute write was possible
|
||||
@@ -797,14 +792,18 @@ export class StyleManager extends ShellFacade {
|
||||
);
|
||||
if (standbyDescription) {
|
||||
standbyDescription.replaceChildren(
|
||||
document.createTextNode(t('cockpit.context.standbyContactsDesc')),
|
||||
document.createTextNode(
|
||||
t('CONTACTS — nearest planes · vessels · sites'),
|
||||
),
|
||||
document.createElement('br'),
|
||||
document.createTextNode(t('cockpit.context.standbyMissionsDesc')),
|
||||
document.createTextNode(
|
||||
t('SPACE MISSIONS — launches & orbital assets'),
|
||||
),
|
||||
);
|
||||
}
|
||||
const briefKicker = document.querySelector('#cockpit-brief-kicker');
|
||||
if (briefKicker) {
|
||||
this._setHeaderText(briefKicker, ` ${t('cockpit.brief.kicker')}`);
|
||||
this._setHeaderText(briefKicker, ` ${t('LIVE SIGNALS')}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,11 +821,8 @@ export class StyleManager extends ShellFacade {
|
||||
// runtime write instead. The locale resolves before UI init and never
|
||||
// changes without a reload, so an init-time write is sufficient.
|
||||
if (this._mapSourceLabel)
|
||||
this._mapSourceLabel.textContent = t('cockpit.presets.mapSourceLabel');
|
||||
this._mapStackChips.setAttribute(
|
||||
'aria-label',
|
||||
t('cockpit.presets.mapSourceChipsAriaLabel'),
|
||||
);
|
||||
this._mapSourceLabel.textContent = t('MAP SOURCE');
|
||||
this._mapStackChips.setAttribute('aria-label', t('Map source'));
|
||||
|
||||
this._mapSourceControls?.destroy();
|
||||
this._mapSourceControls = createMapSourceControls({
|
||||
@@ -1149,7 +1145,7 @@ export class StyleManager extends ShellFacade {
|
||||
const { cctvLayer } = this.services;
|
||||
if (this._disposed) return false;
|
||||
if (!this._dataManager || !this._dataManager.layers?.has('cctv')) {
|
||||
this._showToast(t('cockpit.cctv.toastLayerUnavailable'));
|
||||
this._showToast(t('CCTV layer unavailable'));
|
||||
return false;
|
||||
}
|
||||
const enabled = this._dataManager.isEnabled('cctv');
|
||||
@@ -1598,11 +1594,7 @@ export class StyleManager extends ShellFacade {
|
||||
this._lifetime.listen(this._shareBtn, 'click', async () => {
|
||||
const success = await this.shareLinkManager.copyLink();
|
||||
if (!this._disposed)
|
||||
this._showToast(
|
||||
success
|
||||
? t('cockpit.share.toastCopied')
|
||||
: t('cockpit.share.toastCopyFailed'),
|
||||
);
|
||||
this._showToast(success ? t('Link copied!') : t('Copy failed'));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ export function _initCctvPanel() {
|
||||
},
|
||||
{ origin: 'user' },
|
||||
);
|
||||
this.actions.showToast(t('cockpit.cctv.toastCalibrationSaved'));
|
||||
this.actions.showToast(t('CCTV calibration saved'));
|
||||
});
|
||||
|
||||
this.listen(this._cctvCalibResetBtn, 'click', () => {
|
||||
|
||||
@@ -83,7 +83,7 @@ export function _resetCctvCalibration() {
|
||||
},
|
||||
{ origin: 'user' },
|
||||
);
|
||||
this.actions.showToast(t('cockpit.cctv.toastCalibrationReset'));
|
||||
this.actions.showToast(t('CCTV calibration reset'));
|
||||
}
|
||||
|
||||
export function _beginCctvCalValueEdit(chip) {
|
||||
@@ -164,9 +164,7 @@ export function _syncCctvCalReadout(enabled, activeCamera) {
|
||||
if (this._cctvAdjustBtn) {
|
||||
const adjustOn = !!this._cctvState?.calibrationMode;
|
||||
this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate);
|
||||
this._cctvAdjustBtn.textContent = adjustOn
|
||||
? t('layers.cctv.adjustOn')
|
||||
: t('layers.cctv.adjustLabel');
|
||||
this._cctvAdjustBtn.textContent = adjustOn ? t('ADJUST ON') : t('ADJUST');
|
||||
this._cctvAdjustBtn.disabled = !canCalibrate;
|
||||
}
|
||||
if (this._cctvCalReadout) {
|
||||
|
||||
@@ -88,19 +88,19 @@ export function _settleCctvFrame(token, src, ok) {
|
||||
export function _syncCctvSourceBadge(activeCamera, enabled) {
|
||||
if (!this._cctvSourceBadge) return;
|
||||
if (!enabled || !activeCamera) {
|
||||
this._cctvSourceBadge.textContent = t('layers.cctv.sourceUnknown');
|
||||
this._cctvSourceBadge.textContent = t('SOURCE · UNKNOWN');
|
||||
this._cctvSourceBadge.dataset.frameState = 'idle';
|
||||
return;
|
||||
}
|
||||
const hasDisplayedFrame =
|
||||
this._cctvFrameWrap?.classList.contains('has-frame');
|
||||
if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) {
|
||||
this._cctvSourceBadge.textContent = t('cockpit.cctv.frameLoading');
|
||||
this._cctvSourceBadge.textContent = t('FRAME · LOADING');
|
||||
this._cctvSourceBadge.dataset.frameState = 'loading';
|
||||
return;
|
||||
}
|
||||
if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) {
|
||||
this._cctvSourceBadge.textContent = t('cockpit.cctv.frameUnavailable');
|
||||
this._cctvSourceBadge.textContent = t('FRAME · UNAVAILABLE');
|
||||
this._cctvSourceBadge.dataset.frameState = 'error';
|
||||
return;
|
||||
}
|
||||
|
||||
+48
-43
@@ -1,14 +1,14 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
|
||||
import { createCctvVideoSurface } from './cctvVideo.js';
|
||||
export function _calBadgeLabel(badge) {
|
||||
switch (badge) {
|
||||
case 'calibrated':
|
||||
return t('cockpit.cctv.calBadgeCalibrated');
|
||||
return t('CALIBRATED');
|
||||
case 'curated':
|
||||
return t('cockpit.cctv.calBadgeCurated');
|
||||
return t('CURATED');
|
||||
case 'raw-prior':
|
||||
return t('cockpit.cctv.calBadgeRawPrior');
|
||||
return t('RAW PRIOR');
|
||||
default:
|
||||
return '--';
|
||||
}
|
||||
@@ -53,9 +53,7 @@ export function _renderCctvState(state) {
|
||||
|
||||
if (this._cctvEnableBtn) {
|
||||
this._cctvEnableBtn.classList.toggle('active', enabled);
|
||||
this._cctvEnableBtn.textContent = enabled
|
||||
? t('layers.cctv.toggleOn')
|
||||
: t('layers.cctv.toggleOff');
|
||||
this._cctvEnableBtn.textContent = enabled ? t('CCTV ON') : t('CCTV OFF');
|
||||
}
|
||||
|
||||
if (this._cctvSelect) {
|
||||
@@ -103,10 +101,10 @@ export function _renderCctvState(state) {
|
||||
this._cctvCoverageBtn.classList.toggle('active', mode !== 'off');
|
||||
this._cctvCoverageBtn.textContent =
|
||||
mode === 'viewshed'
|
||||
? t('cockpit.cctv.coverageViewshedOn')
|
||||
? t('VIEWSHED ON')
|
||||
: mode === 'on'
|
||||
? t('layers.cctv.coverageOn')
|
||||
: t('layers.cctv.coverageOff');
|
||||
? t('COVERAGE ON')
|
||||
: t('COVERAGE OFF');
|
||||
this._cctvCoverageBtn.disabled = !enabled;
|
||||
}
|
||||
|
||||
@@ -114,8 +112,8 @@ export function _renderCctvState(state) {
|
||||
const autoHop = !!state?.autoHop;
|
||||
this._cctvAutoHopBtn.classList.toggle('active', autoHop);
|
||||
this._cctvAutoHopBtn.textContent = autoHop
|
||||
? t('layers.cctv.autoHopOn')
|
||||
: t('layers.cctv.autoHopOff');
|
||||
? t('AUTO HOP ON')
|
||||
: t('AUTO HOP OFF');
|
||||
this._cctvAutoHopBtn.disabled = !enabled;
|
||||
}
|
||||
|
||||
@@ -123,8 +121,8 @@ export function _renderCctvState(state) {
|
||||
const showProjection = state?.showProjection !== false;
|
||||
this._cctvProjectionBtn.classList.toggle('active', showProjection);
|
||||
this._cctvProjectionBtn.textContent = showProjection
|
||||
? t('layers.cctv.projectionOn')
|
||||
: t('layers.cctv.projectionOff');
|
||||
? t('PROJECTION ON')
|
||||
: t('PROJECTION OFF');
|
||||
this._cctvProjectionBtn.disabled = !enabled;
|
||||
}
|
||||
|
||||
@@ -139,8 +137,8 @@ export function _renderCctvState(state) {
|
||||
const badge = activeCamera?.calBadge || null;
|
||||
const dirty = !!activeCamera?.calDirty;
|
||||
this._cctvQualityChip.textContent = dirty
|
||||
? t('cockpit.cctv.calChipEdited')
|
||||
: t('cockpit.cctv.calChipTemplate', {
|
||||
? t('CAL · EDITED (UNSAVED)')
|
||||
: t('CAL · {badge}', {
|
||||
badge: this._calBadgeLabel(badge),
|
||||
});
|
||||
this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || '';
|
||||
@@ -163,25 +161,36 @@ export function _renderCctvState(state) {
|
||||
? this._calBadgeLabel(activeCamera.calBadge)
|
||||
: '';
|
||||
const projLabel =
|
||||
state?.showProjection !== false
|
||||
? t('cockpit.cctv.metaProjectionMonitor')
|
||||
: t('cockpit.cctv.metaProjectionOff');
|
||||
this._cctvMeta.textContent = t('cockpit.cctv.metaTemplate', {
|
||||
city: activeCamera.city,
|
||||
heading: `${Math.round(activeCamera.headingDeg)}°`,
|
||||
fov: `${Math.round(activeCamera.fovDeg)}°`,
|
||||
range: Math.round(activeCamera.rangeM),
|
||||
projection: projLabel,
|
||||
calBadge: calBadge ? ` · ${calBadge}` : '',
|
||||
provider,
|
||||
status: `${credit}${statusMsg}`,
|
||||
});
|
||||
state?.showProjection !== false ? t('MONITOR') : t('OFF');
|
||||
this._cctvMeta.textContent = t(
|
||||
'{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}',
|
||||
{
|
||||
city: activeCamera.city,
|
||||
heading: `${Math.round(activeCamera.headingDeg)}°`,
|
||||
fov: `${Math.round(activeCamera.fovDeg)}°`,
|
||||
range: Math.round(activeCamera.rangeM),
|
||||
projection: projLabel,
|
||||
calBadge: calBadge ? ` · ${calBadge}` : '',
|
||||
provider,
|
||||
status: `${credit}${statusMsg}`,
|
||||
},
|
||||
);
|
||||
} else if (cameras.length > 0) {
|
||||
this._cctvMeta.textContent = enabled
|
||||
? t('cockpit.cctv.metaCamerasClick', { count: cameras.length })
|
||||
: t('cockpit.cctv.metaCamerasEnable', { count: cameras.length });
|
||||
? tn(
|
||||
cameras.length,
|
||||
'{count} camera loaded · click a camera to activate',
|
||||
'{count} cameras loaded · click a camera to activate',
|
||||
)
|
||||
: tn(
|
||||
cameras.length,
|
||||
'{count} camera loaded · enable CCTV to activate',
|
||||
'{count} cameras loaded · enable CCTV to activate',
|
||||
);
|
||||
} else {
|
||||
this._cctvMeta.textContent = t('layers.cctv.metaIdle');
|
||||
this._cctvMeta.textContent = t(
|
||||
'Enable CCTV to load camera intersections',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,13 +238,15 @@ export function _renderCctvState(state) {
|
||||
}
|
||||
|
||||
this._syncCctvSourceBadge(activeCamera, enabled);
|
||||
this._typeCctvSummary(state?.summary || t('layers.cctv.summaryIdle'));
|
||||
this._typeCctvSummary(
|
||||
state?.summary ||
|
||||
t('Enable CCTV to start camera-linked intelligence summaries.'),
|
||||
);
|
||||
}
|
||||
|
||||
export function _typeCctvSummary(text) {
|
||||
if (this.destroyed || !this._cctvSummary) return;
|
||||
const nextText =
|
||||
String(text || '').trim() || t('cockpit.cctv.summaryNoneAvailable');
|
||||
const nextText = String(text || '').trim() || t('No summary available.');
|
||||
if (nextText === this._lastCctvSummaryText) return;
|
||||
this._lastCctvSummaryText = nextText;
|
||||
|
||||
@@ -267,10 +278,7 @@ export function _updateCctvSyncChip(loading, enabled) {
|
||||
clearTimeout(this._cctvChipHideTimer);
|
||||
this._cctvChipHideTimer = null;
|
||||
this._cctvChipWasBusy = true;
|
||||
this.actions.setSplitFlapText(
|
||||
this._cctvSyncLabel,
|
||||
t('cockpit.cctv.syncLoadingFrames'),
|
||||
);
|
||||
this.actions.setSplitFlapText(this._cctvSyncLabel, t('loading frames'));
|
||||
// The counter is left plain on purpose: it ticks every few frames
|
||||
// during a grid load, and flapping it would read as a slot machine.
|
||||
this._cctvSyncProgress.textContent = `${loaded}/${total}`;
|
||||
@@ -281,10 +289,7 @@ export function _updateCctvSyncChip(loading, enabled) {
|
||||
if (this._cctvChipWasBusy && enabled && total > 0) {
|
||||
// Load just completed — flash the final count, then auto-hide.
|
||||
this._cctvChipWasBusy = false;
|
||||
this.actions.setSplitFlapText(
|
||||
this._cctvSyncLabel,
|
||||
t('cockpit.cctv.syncGridReady'),
|
||||
);
|
||||
this.actions.setSplitFlapText(this._cctvSyncLabel, t('camera grid ready'));
|
||||
this._cctvSyncProgress.textContent = `${total}/${total}`;
|
||||
this._cctvSyncChip.classList.add('visible');
|
||||
clearTimeout(this._cctvChipHideTimer);
|
||||
|
||||
@@ -15,8 +15,8 @@ export function bindClearLayersControl(button, clear) {
|
||||
button.setAttribute(
|
||||
'aria-label',
|
||||
busy
|
||||
? t('cockpit.actions.clearLayersBusyAria')
|
||||
: t('shell.actions.clearLayers.ariaLabel'),
|
||||
? t('Clearing selected data layers')
|
||||
: t('Clear selected data layers'),
|
||||
);
|
||||
},
|
||||
destroy() {
|
||||
|
||||
+35
-35
@@ -1,5 +1,5 @@
|
||||
/** Regional briefing requests, pages and rotation for the Cockpit controller. */
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import {
|
||||
COCKPIT_BRIEF_ROTATE_MS,
|
||||
COCKPIT_BRIEF_CYCLE_OFF_HELP,
|
||||
@@ -15,18 +15,18 @@ import {
|
||||
// so it returns stable English labels; localize them here, at the cockpit
|
||||
// presentation boundary (docs/TRANSLATORS.md).
|
||||
const WEATHER_CONDITION_KEYS = Object.freeze({
|
||||
'CONDITIONS UNKNOWN': 'layers.weather.conditionsUnknown',
|
||||
CLEAR: 'layers.weather.clear',
|
||||
'PARTLY CLOUDY': 'layers.weather.partlyCloudy',
|
||||
OVERCAST: 'layers.weather.overcast',
|
||||
FOG: 'layers.weather.fog',
|
||||
DRIZZLE: 'layers.weather.drizzle',
|
||||
RAIN: 'layers.weather.rain',
|
||||
SNOW: 'layers.weather.snow',
|
||||
'RAIN SHOWERS': 'layers.weather.rainShowers',
|
||||
'SNOW SHOWERS': 'layers.weather.snowShowers',
|
||||
THUNDERSTORM: 'layers.weather.thunderstorm',
|
||||
'MIXED CONDITIONS': 'layers.weather.mixed',
|
||||
'CONDITIONS UNKNOWN': msg('CONDITIONS UNKNOWN'),
|
||||
CLEAR: msg('CLEAR'),
|
||||
'PARTLY CLOUDY': msg('PARTLY CLOUDY'),
|
||||
OVERCAST: msg('OVERCAST'),
|
||||
FOG: msg('FOG'),
|
||||
DRIZZLE: msg('DRIZZLE'),
|
||||
RAIN: msg('RAIN'),
|
||||
SNOW: msg('SNOW'),
|
||||
'RAIN SHOWERS': msg('RAIN SHOWERS'),
|
||||
'SNOW SHOWERS': msg('SNOW SHOWERS'),
|
||||
THUNDERSTORM: msg('THUNDERSTORM'),
|
||||
'MIXED CONDITIONS': msg('MIXED CONDITIONS'),
|
||||
});
|
||||
|
||||
export function showBriefPage(index, { manual = false } = {}) {
|
||||
@@ -80,14 +80,18 @@ export function setBriefAutoRotate(enabled) {
|
||||
// these t() re-writes are same-value overrides that localize the toggle
|
||||
// under other locales (byte-identical under 'en').
|
||||
const labelKey = this.briefAutoRotateEnabled
|
||||
? 'cockpit.brief.autoOn'
|
||||
: 'cockpit.brief.autoOff';
|
||||
? msg('CYCLE ON')
|
||||
: msg('CYCLE OFF');
|
||||
this.briefAutoToggle.textContent = t(labelKey);
|
||||
this.briefAutoToggle.setAttribute('aria-label', t(labelKey));
|
||||
this.briefAutoToggle.title = t(
|
||||
this.briefAutoRotateEnabled
|
||||
? 'cockpit.brief.autoTitleOn'
|
||||
: 'cockpit.brief.autoTitle',
|
||||
? msg(
|
||||
'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.',
|
||||
)
|
||||
: msg(
|
||||
'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
|
||||
@@ -127,7 +131,7 @@ export function stopBriefRotation() {
|
||||
export function updateLocalPosition(info) {
|
||||
if (!this.localCoordinates) return;
|
||||
if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) {
|
||||
this.localCoordinates.textContent = t('cockpit.brief.positionUnavailable');
|
||||
this.localCoordinates.textContent = t('POSITION UNAVAILABLE');
|
||||
return;
|
||||
}
|
||||
const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`;
|
||||
@@ -208,14 +212,14 @@ export function renderRegionalBriefStatus(status, info) {
|
||||
this.newsStatus.dataset.state = status;
|
||||
this.newsStatus.textContent =
|
||||
status === 'loading'
|
||||
? t('cockpit.brief.newsAcquiring')
|
||||
: t('cockpit.brief.newsUnavailable');
|
||||
? t('ACQUIRING REGIONAL NEWS')
|
||||
: t('REGIONAL NEWS UNAVAILABLE');
|
||||
}
|
||||
if (status === 'unavailable') this.newsList?.replaceChildren();
|
||||
if (this.localPlace && status === 'loading')
|
||||
this.localPlace.textContent = t('cockpit.brief.localResolving');
|
||||
this.localPlace.textContent = t('RESOLVING REGION');
|
||||
if (this.localPlace && status === 'unavailable')
|
||||
this.localPlace.textContent = t('cockpit.brief.regionUnavailable');
|
||||
this.localPlace.textContent = t('REGION UNAVAILABLE');
|
||||
this.updateLocalPosition(info);
|
||||
}
|
||||
|
||||
@@ -226,8 +230,8 @@ export function renderRegionalBrief(payload, info) {
|
||||
this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable';
|
||||
this.newsStatus.textContent =
|
||||
payload?.newsStatus === 'empty'
|
||||
? t('cockpit.brief.newsEmpty')
|
||||
: t('cockpit.brief.newsUnavailable');
|
||||
? t('NO RECENT LOCATION MATCHES')
|
||||
: t('REGIONAL NEWS UNAVAILABLE');
|
||||
}
|
||||
if (this.newsList) {
|
||||
this.newsList.replaceChildren(
|
||||
@@ -240,8 +244,8 @@ export function renderRegionalBrief(payload, info) {
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = article.title;
|
||||
const metadata = document.createElement('span');
|
||||
metadata.textContent = t('cockpit.brief.articleMetaTemplate', {
|
||||
domain: article.domain || t('cockpit.brief.metadataSourceFallback'),
|
||||
metadata.textContent = t('{domain} · {age}', {
|
||||
domain: article.domain || t('SOURCE'),
|
||||
age: formatCockpitBriefAge(article.publishedAt),
|
||||
});
|
||||
link.append(title, metadata);
|
||||
@@ -252,9 +256,7 @@ export function renderRegionalBrief(payload, info) {
|
||||
}
|
||||
|
||||
const placeLabel =
|
||||
payload?.place?.label ||
|
||||
payload?.place?.country ||
|
||||
t('cockpit.brief.regionUnavailable');
|
||||
payload?.place?.label || payload?.place?.country || t('REGION UNAVAILABLE');
|
||||
if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase();
|
||||
this.updateLocalPosition(info);
|
||||
const weather = payload?.weather;
|
||||
@@ -282,10 +284,10 @@ export function renderRegionalBrief(payload, info) {
|
||||
}
|
||||
if (this.localCloud) {
|
||||
this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct)
|
||||
? t('cockpit.brief.cloudTemplate', {
|
||||
? t('CLOUD {pct}%', {
|
||||
pct: Math.round(weather.cloudCoverPct),
|
||||
})
|
||||
: t('cockpit.brief.cloudUnknown');
|
||||
: t('CLOUD UNKNOWN');
|
||||
}
|
||||
if (this.localPrecipitation) {
|
||||
this.localPrecipitation.textContent = Number.isFinite(
|
||||
@@ -297,10 +299,8 @@ export function renderRegionalBrief(payload, info) {
|
||||
if (this.signalStream)
|
||||
this.signalStream.dataset.regionalStatus = payload?.status || 'partial';
|
||||
if (this.briefPageIndex === 1 && this.briefSource) {
|
||||
this.briefSource.textContent = t('cockpit.brief.newsSourceLine', {
|
||||
source: String(
|
||||
payload?.newsSource || t('cockpit.brief.kickerNews'),
|
||||
).toUpperCase(),
|
||||
this.briefSource.textContent = t('{source} · LOCATION QUERY', {
|
||||
source: String(payload?.newsSource || t('REGIONAL NEWS')).toUpperCase(),
|
||||
});
|
||||
}
|
||||
this.scheduleContextLayout();
|
||||
|
||||
+19
-19
@@ -1,6 +1,6 @@
|
||||
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
import {
|
||||
bearingBetweenCoordinates,
|
||||
formatCockpitContextScope,
|
||||
@@ -21,8 +21,8 @@ export function updateContext(info, heading) {
|
||||
this.pushCockpitSignal(
|
||||
'context-status',
|
||||
'info',
|
||||
t('cockpit.signal.contextStandby'),
|
||||
t('cockpit.signal.contextStandbyHint'),
|
||||
t('CONTEXT STANDBY'),
|
||||
t('ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export function updateContext(info, heading) {
|
||||
this.contextUncertainty.textContent =
|
||||
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR';
|
||||
this.contextUncertainty.textContent = t(
|
||||
'cockpit.context.uncertaintyContactLost',
|
||||
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR',
|
||||
);
|
||||
}
|
||||
// The cue changes the footer's height; re-run layout once on the way in
|
||||
@@ -68,10 +68,10 @@ export function updateContext(info, heading) {
|
||||
this.pushCockpitSignal(
|
||||
'context-status',
|
||||
'warning',
|
||||
t('cockpit.signal.contactLostTitle', {
|
||||
t('CONTACT LOST · {subject}', {
|
||||
subject: snapshot.subject.label || snapshot.subject.id || 'SUBJECT',
|
||||
}),
|
||||
t('cockpit.signal.contactLostDetail'),
|
||||
t('SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -92,15 +92,15 @@ export function updateContext(info, heading) {
|
||||
const closestLabel = this.services.formatAwarenessLabel(closest);
|
||||
if (this.contextNearestLabel) {
|
||||
this.contextNearestLabel.textContent = closest
|
||||
? t('cockpit.context.nearestTemplate', {
|
||||
? t('{cohort} · {contact}', {
|
||||
cohort: closest.cohort.label.toUpperCase(),
|
||||
contact: closestLabel,
|
||||
})
|
||||
: t('cockpit.context.nearestEmpty');
|
||||
: t('NO AVAILABLE EXAMPLE');
|
||||
this.contextNearestLabel.setAttribute(
|
||||
'aria-label',
|
||||
closest && closestLabel === '—'
|
||||
? t('cockpit.context.nearestUnavailableAria', {
|
||||
? t('{cohort}, Unavailable', {
|
||||
cohort: closest.cohort.label,
|
||||
})
|
||||
: this.contextNearestLabel.textContent,
|
||||
@@ -153,24 +153,24 @@ export function updateContext(info, heading) {
|
||||
else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD';
|
||||
else
|
||||
this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`;
|
||||
if (relative === null)
|
||||
this.contextBearing.textContent = t('cockpit.context.bearingNone');
|
||||
if (relative === null) this.contextBearing.textContent = t('BRG —');
|
||||
else if (Math.abs(relative) < 8)
|
||||
this.contextBearing.textContent = t('cockpit.context.bearingAhead');
|
||||
this.contextBearing.textContent = t('AHEAD');
|
||||
else {
|
||||
this.contextBearing.textContent = t('cockpit.context.bearingSide', {
|
||||
side:
|
||||
relative < 0
|
||||
? t('cockpit.context.sideLeft')
|
||||
: t('cockpit.context.sideRight'),
|
||||
this.contextBearing.textContent = t('{side} {angle}', {
|
||||
side: relative < 0 ? t('L') : t('R'),
|
||||
angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.contextUncertainty) {
|
||||
this.contextUncertainty.textContent = unknownCount
|
||||
? t('cockpit.context.uncertaintyInputsUnknown', { count: unknownCount })
|
||||
: t('cockpit.context.uncertaintyInputsCurrent');
|
||||
? tn(
|
||||
unknownCount,
|
||||
'{count} INPUT UNKNOWN · NOT AN ALL-CLEAR',
|
||||
'{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR',
|
||||
)
|
||||
: t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR');
|
||||
}
|
||||
if (this.contextUpdated) {
|
||||
this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt)
|
||||
|
||||
@@ -32,10 +32,7 @@ export function updateHud(
|
||||
const heading = normalizeHeading(this.heading ?? info.track ?? 0);
|
||||
if (this.callsign) {
|
||||
this.callsign.textContent =
|
||||
info.callsign ||
|
||||
info.registration ||
|
||||
info.icao24 ||
|
||||
t('cockpit.hud.fallbackCallsign');
|
||||
info.callsign || info.registration || info.icao24 || t('AIRCRAFT');
|
||||
}
|
||||
const speedKt = Number.isFinite(info.velocityMps)
|
||||
? info.velocityMps * 1.94384
|
||||
@@ -133,19 +130,20 @@ export function updateHud(
|
||||
// State-sibling keys at the writer: each feed state translates
|
||||
// independently so no state can leak English under other locales.
|
||||
const feedState = this.surfaceAcquiring
|
||||
? t('cockpit.hud.metaFeedAcquiringSurface')
|
||||
? t('ACQUIRING SURFACE')
|
||||
: this.surfaceFallback
|
||||
? t('cockpit.hud.metaFeedSurfaceFallback')
|
||||
? t('SURFACE FALLBACK')
|
||||
: info.stale
|
||||
? t('cockpit.hud.metaFeedStale')
|
||||
: t('cockpit.hud.metaFeedLive');
|
||||
this.aircraftMeta.textContent = t('cockpit.hud.aircraftMetaTemplate', {
|
||||
aircraftClass:
|
||||
info.layerId === 'military'
|
||||
? t('cockpit.hud.metaClassMilitary')
|
||||
: t('cockpit.hud.metaClassCommercial'),
|
||||
feedState,
|
||||
});
|
||||
? t('STALE FEED')
|
||||
: t('LIVE TRACK');
|
||||
this.aircraftMeta.textContent = t(
|
||||
'{aircraftClass} · {feedState} · COURSE ALIGNED',
|
||||
{
|
||||
aircraftClass:
|
||||
info.layerId === 'military' ? t('MILITARY') : t('COMMERCIAL'),
|
||||
feedState,
|
||||
},
|
||||
);
|
||||
}
|
||||
this.updateRoute(info);
|
||||
if (
|
||||
@@ -170,14 +168,13 @@ export function updateRoute(info) {
|
||||
const validDestination =
|
||||
Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
|
||||
const routeLabel = (airport) =>
|
||||
[airport?.code, airport?.name].filter(Boolean).join(' · ') ||
|
||||
t('cockpit.route.unknownEndpoint');
|
||||
[airport?.code, airport?.name].filter(Boolean).join(' · ') || t('UNKNOWN');
|
||||
if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin);
|
||||
if (this.routeTo) this.routeTo.textContent = routeLabel(destination);
|
||||
if (this.routeStatus) {
|
||||
this.routeStatus.textContent = validDestination
|
||||
? t('cockpit.route.statusArrowEstimated')
|
||||
: t('cockpit.route.statusUnavailable');
|
||||
? t('ARROW · ESTIMATED DIRECTION')
|
||||
: t('ROUTE DATA UNAVAILABLE');
|
||||
}
|
||||
if (this.route) this.route.hidden = !origin && !destination;
|
||||
if (
|
||||
@@ -211,7 +208,7 @@ export function updateRoute(info) {
|
||||
);
|
||||
}
|
||||
if (this.routeDirectionLabel) {
|
||||
this.routeDirectionLabel.textContent = t('cockpit.route.directionLabel', {
|
||||
this.routeDirectionLabel.textContent = t('DEST {bearing}', {
|
||||
bearing: `${String(Math.round(destinationBearing)).padStart(3, '0')}°`,
|
||||
});
|
||||
}
|
||||
@@ -222,14 +219,12 @@ export function syncWeatherToggle(enabled) {
|
||||
const active = !!enabled;
|
||||
this.weatherToggle.setAttribute('aria-pressed', String(active));
|
||||
const weatherLabel = active
|
||||
? t('cockpit.context.weatherDisableAriaLabel')
|
||||
: t('cockpit.context.weatherEnableAriaLabel');
|
||||
? t('Disable cockpit weather effects')
|
||||
: t('Enable cockpit weather effects');
|
||||
this.weatherToggle.setAttribute('aria-label', weatherLabel);
|
||||
this.weatherToggle.title = weatherLabel;
|
||||
if (this.weatherState) {
|
||||
this.weatherState.textContent = active
|
||||
? t('cockpit.context.weatherStateOn')
|
||||
: t('cockpit.context.weatherStateOff');
|
||||
this.weatherState.textContent = active ? t('ON') : t('OFF');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,19 +243,21 @@ export function setVisionMode(mode, { revealParameters = false } = {}) {
|
||||
const names = {
|
||||
optical: 'Normal',
|
||||
crt: 'CRT',
|
||||
nvg: t('cockpit.vision.styleNameNightVision'),
|
||||
thermal: t('cockpit.vision.styleNameThermal'),
|
||||
anime: t('cockpit.vision.styleNameAnime'),
|
||||
noir: t('cockpit.vision.styleNameNoir'),
|
||||
snow: t('cockpit.vision.styleNameSnow'),
|
||||
nvg: t('Night vision'),
|
||||
thermal: t('Thermal'),
|
||||
anime: t('Anime'),
|
||||
noir: t('Noir'),
|
||||
snow: t('Snow'),
|
||||
};
|
||||
if (this.visionCurrent) {
|
||||
this.visionCurrent.dataset.cockpitVision = next;
|
||||
this.visionCurrent.setAttribute(
|
||||
'aria-label',
|
||||
t('cockpit.vision.currentAriaTemplate', { style: names[next] }),
|
||||
t('Current cockpit vision style: {style}. Activate for next style.', {
|
||||
style: names[next],
|
||||
}),
|
||||
);
|
||||
this.visionCurrent.title = t('cockpit.vision.currentTitleTemplate', {
|
||||
this.visionCurrent.title = t('Current style: {style} — click for next', {
|
||||
style: names[next],
|
||||
});
|
||||
}
|
||||
|
||||
+6
-12
@@ -1,5 +1,5 @@
|
||||
/** Measure Cockpit panels against visible HUD and viewport obstacles. */
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import {
|
||||
resolveCockpitUtilityAnchor,
|
||||
resolveCockpitUtilityLayout,
|
||||
@@ -45,16 +45,10 @@ export function setContextCollapsed(collapsed) {
|
||||
);
|
||||
this.contextToggle.setAttribute(
|
||||
'aria-label',
|
||||
t(
|
||||
expanded
|
||||
? 'cockpit.context.toggleExpandAriaLabel'
|
||||
: 'cockpit.context.toggleAriaLabel',
|
||||
),
|
||||
t(expanded ? msg('Expand Contact panel') : msg('Collapse Contact panel')),
|
||||
);
|
||||
this.contextToggle.title = t(
|
||||
expanded
|
||||
? 'cockpit.context.toggleExpandTitle'
|
||||
: 'cockpit.context.toggleTitle',
|
||||
expanded ? msg('Expand contact panel') : msg('Collapse contact panel'),
|
||||
);
|
||||
const icon = this.contextToggle.querySelector('.material-symbols-outlined');
|
||||
if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right';
|
||||
@@ -78,12 +72,12 @@ export function setSignalCollapsed(collapsed, { user = false } = {}) {
|
||||
'aria-label',
|
||||
t(
|
||||
expanded
|
||||
? 'cockpit.brief.collapseAriaLabel'
|
||||
: 'cockpit.brief.expandAriaLabel',
|
||||
? msg('Collapse cockpit briefing panel')
|
||||
: msg('Expand cockpit briefing panel'),
|
||||
),
|
||||
);
|
||||
this.signalToggle.title = t(
|
||||
expanded ? 'cockpit.brief.collapseTitle' : 'cockpit.brief.expandTitle',
|
||||
expanded ? msg('Collapse briefing panel') : msg('Expand briefing panel'),
|
||||
);
|
||||
const icon = this.signalToggle.querySelector('.material-symbols-outlined');
|
||||
if (icon)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Pure formatting and fixed presentation constants used by Cockpit components. */
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
|
||||
export const COCKPIT_HEADING_SLEW_DPS = 28;
|
||||
|
||||
@@ -46,21 +46,21 @@ export const COCKPIT_REGIONAL_REFRESH_DISTANCE_M = 25_000;
|
||||
export const COCKPIT_BRIEF_PAGES = [
|
||||
{
|
||||
id: 'signals',
|
||||
kickerKey: 'cockpit.brief.kicker',
|
||||
subtitleKey: 'cockpit.brief.subtitle',
|
||||
sourceKey: 'cockpit.brief.sourceNote',
|
||||
kickerKey: msg('LIVE SIGNALS'),
|
||||
subtitleKey: msg('OBSERVED / MAPPED PINGS'),
|
||||
sourceKey: msg('SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS'),
|
||||
},
|
||||
{
|
||||
id: 'news',
|
||||
kickerKey: 'cockpit.brief.kickerNews',
|
||||
subtitleKey: 'cockpit.brief.subtitleNews',
|
||||
kickerKey: msg('REGIONAL NEWS'),
|
||||
subtitleKey: msg('LATEST LOCATION-MATCHED REPORTING'),
|
||||
// RSS provider attribution is a keep-English boundary string.
|
||||
source: 'GOOGLE NEWS RSS · LOCATION QUERY · RECENT',
|
||||
},
|
||||
{
|
||||
id: 'local',
|
||||
kickerKey: 'cockpit.brief.kickerLocal',
|
||||
subtitleKey: 'cockpit.brief.subtitleLocal',
|
||||
kickerKey: msg('LOCAL INFO'),
|
||||
subtitleKey: msg('PLACE / CONDITIONS / POSITION'),
|
||||
// Data-source attribution is a keep-English boundary string.
|
||||
source: 'NATURAL EARTH · OPEN-METEO · UTC',
|
||||
},
|
||||
@@ -84,17 +84,17 @@ export function isRenderedOnScreen(element) {
|
||||
|
||||
export function formatCockpitBriefAge(value) {
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) return t('cockpit.brief.age.timeUnknown');
|
||||
if (!Number.isFinite(timestamp)) return t('TIME UNKNOWN');
|
||||
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
|
||||
if (minutes < 60) return t('cockpit.brief.age.minutes', { count: minutes });
|
||||
if (minutes < 60) return t('{count}M AGO', { count: minutes });
|
||||
const hours = Math.round(minutes / 60);
|
||||
return hours < 48
|
||||
? t('cockpit.brief.age.hours', { count: hours })
|
||||
: t('cockpit.brief.age.days', { count: Math.round(hours / 24) });
|
||||
? t('{count}H AGO', { count: hours })
|
||||
: t('{count}D AGO', { count: Math.round(hours / 24) });
|
||||
}
|
||||
|
||||
export function formatCockpitWindDirection(value) {
|
||||
if (!Number.isFinite(value)) return t('cockpit.brief.wind.dirUnknown');
|
||||
if (!Number.isFinite(value)) return t('DIR UNKNOWN');
|
||||
const labels = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
const normalized = ((value % 360) + 360) % 360;
|
||||
return `${labels[Math.round(normalized / 45) % labels.length]} · ${Math.round(normalized)}°`;
|
||||
|
||||
+15
-11
@@ -1,5 +1,5 @@
|
||||
/** Reconcile actionable signal rows without disturbing native keyboard focus. */
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
|
||||
export function renderCockpitSignals() {
|
||||
if (!this.signalList) return;
|
||||
@@ -58,7 +58,7 @@ export function renderCockpitSignals() {
|
||||
if (item.target) {
|
||||
heading.dataset.signalLayer = item.target.layerId;
|
||||
heading.dataset.signalId = item.target.id;
|
||||
const label = t('cockpit.signal.selectFlightAria', { title: item.title });
|
||||
const label = t('Select flight {title}', { title: item.title });
|
||||
if (heading.getAttribute('aria-label') !== label)
|
||||
heading.setAttribute('aria-label', label);
|
||||
setText(heading.children[0], item.title);
|
||||
@@ -127,11 +127,11 @@ export function updateCockpitSignals(snapshot, unknownCount) {
|
||||
key: `flight:${subject.layerId}:${subject.id}`,
|
||||
tone: 'track',
|
||||
title: subject.label || subject.id,
|
||||
detail: t('cockpit.signal.contactCurrent', {
|
||||
detail: t('{aircraftClass} · CURRENT', {
|
||||
aircraftClass:
|
||||
subject.layerId === 'military'
|
||||
? t('cockpit.signal.classMilitary')
|
||||
: t('cockpit.signal.classCommercial'),
|
||||
? t('MILITARY FLIGHT')
|
||||
: t('COMMERCIAL FLIGHT'),
|
||||
}),
|
||||
target: { layerId: subject.layerId, id: String(subject.id) },
|
||||
distanceM: -1,
|
||||
@@ -151,14 +151,14 @@ export function updateCockpitSignals(snapshot, unknownCount) {
|
||||
// contact reads as its registration here too. Same helper the
|
||||
// Context panel's nearest list uses.
|
||||
title: this.services.formatAwarenessLabel(item),
|
||||
detail: t('cockpit.signal.contactRange', {
|
||||
detail: t('{aircraftClass} · {distance}', {
|
||||
aircraftClass:
|
||||
cohort.id === 'military'
|
||||
? t('cockpit.signal.classMilitary')
|
||||
: t('cockpit.signal.classCommercial'),
|
||||
? t('MILITARY FLIGHT')
|
||||
: t('COMMERCIAL FLIGHT'),
|
||||
distance: Number.isFinite(item.distanceM)
|
||||
? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM`
|
||||
: t('cockpit.signal.distanceUnknown'),
|
||||
: t('DISTANCE UNKNOWN'),
|
||||
}),
|
||||
target: { layerId: cohort.id, id: String(id) },
|
||||
distanceM: item.distanceM ?? Infinity,
|
||||
@@ -179,8 +179,12 @@ export function updateCockpitSignals(snapshot, unknownCount) {
|
||||
nextItems.splice(4, Math.max(0, nextItems.length - 4), {
|
||||
key: 'input-status',
|
||||
tone: 'warning',
|
||||
title: t('cockpit.signal.inputsUnknown', { count: unknownCount }),
|
||||
detail: sources || t('cockpit.signal.sourceStatusUnavailable'),
|
||||
title: tn(
|
||||
unknownCount,
|
||||
'{count} INPUT UNKNOWN',
|
||||
'{count} INPUTS UNKNOWN',
|
||||
),
|
||||
detail: sources || t('SOURCE STATUS UNAVAILABLE'),
|
||||
target: null,
|
||||
timestamp:
|
||||
previous.get('input-status')?.timestamp ||
|
||||
|
||||
@@ -59,8 +59,8 @@ export function syncTr3bToggle(info) {
|
||||
this.tr3bToggle.hidden = !icao24;
|
||||
this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false');
|
||||
this.tr3bToggle.title = converted
|
||||
? t('cockpit.context.tr3bRestoreTitle')
|
||||
: t('cockpit.context.tr3bTitle');
|
||||
? t('Restore real aircraft')
|
||||
: t('Reclassify as TR-3B');
|
||||
}
|
||||
|
||||
export function syncEntry() {
|
||||
@@ -184,20 +184,17 @@ export function enter() {
|
||||
this.showBriefPage(0);
|
||||
this.startBriefRotation();
|
||||
const trackLabel =
|
||||
info.callsign ||
|
||||
info.registration ||
|
||||
info.icao24 ||
|
||||
t('cockpit.hud.fallbackCallsign');
|
||||
info.callsign || info.registration || info.icao24 || t('AIRCRAFT');
|
||||
const trackHeading = String(
|
||||
Math.round(normalizeHeading(info.track ?? 0)),
|
||||
).padStart(3, '0');
|
||||
this.pushCockpitSignal(
|
||||
'track',
|
||||
'track',
|
||||
t('cockpit.signal.trackAcquired'),
|
||||
t('cockpit.signal.trackDetail', {
|
||||
t('TRACK ACQUIRED'),
|
||||
t('{label} · COURSE {heading}°', {
|
||||
label: trackLabel,
|
||||
heading: `${trackHeading}°`,
|
||||
heading: trackHeading,
|
||||
}),
|
||||
);
|
||||
this.updateHud(info, performance.now(), true);
|
||||
|
||||
+14
-10
@@ -1,4 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
import {
|
||||
settleUserFacingContextAction,
|
||||
contextModeWord,
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
|
||||
export async function _runUserFacingContextAction(
|
||||
operation,
|
||||
message = t('cockpit.context.toastRestoreFailed'),
|
||||
message = t('Context could not restore every layer; try again'),
|
||||
{ falseIsFailure = true, localizedMessage = null } = {},
|
||||
) {
|
||||
if (this.destroyed) return false;
|
||||
@@ -223,25 +223,29 @@ export function clearSelectedLayers() {
|
||||
const operation = managerOperation
|
||||
.then((result) => {
|
||||
if (result.targetIds.length === 0) {
|
||||
this.showToast(t('layers.clear.toast.noneSelected'));
|
||||
this.showToast(t('No selected data layers'));
|
||||
} else if (result.notClearedIds.length > 0) {
|
||||
this.showToast(
|
||||
t('layers.clear.toast.notCleared', {
|
||||
count: result.notClearedIds.length,
|
||||
}),
|
||||
tn(
|
||||
result.notClearedIds.length,
|
||||
'{count} data layer could not be cleared',
|
||||
'{count} data layers could not be cleared',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
this.showToast(
|
||||
t('layers.clear.toast.cleared', {
|
||||
count: result.clearedIds.length,
|
||||
}),
|
||||
tn(
|
||||
result.clearedIds.length,
|
||||
'Cleared {count} data layer',
|
||||
'Cleared {count} data layers',
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[Data] clear selected layers failed', error);
|
||||
this.showToast(t('cockpit.actions.clearLayersFailedToast'));
|
||||
this.showToast(t('Selected data layers could not be cleared'));
|
||||
return {
|
||||
targetIds: [],
|
||||
items: [],
|
||||
|
||||
@@ -33,7 +33,7 @@ export function _initGlobalContextPanel() {
|
||||
void this._runUserFacingContextAction(
|
||||
(notificationToken) =>
|
||||
this._selectContextMode(nextMode, { notificationToken }),
|
||||
t('cockpit.context.toastTransitionFailedContacts'),
|
||||
t('Contacts could not complete the requested transition; try again'),
|
||||
).then((succeeded) => {
|
||||
if (
|
||||
!this.destroyed &&
|
||||
@@ -62,7 +62,9 @@ export function _initGlobalContextPanel() {
|
||||
void this._runUserFacingContextAction(
|
||||
(notificationToken) =>
|
||||
this._selectContextMode(nextMode, { notificationToken }),
|
||||
t('cockpit.context.toastTransitionFailedMissions'),
|
||||
t(
|
||||
'Space Missions could not complete the requested transition; try again',
|
||||
),
|
||||
).then((succeeded) => {
|
||||
if (
|
||||
!this.destroyed &&
|
||||
@@ -109,14 +111,16 @@ export function _initGlobalContextPanel() {
|
||||
this.showToast(
|
||||
stats?.statusMessage ||
|
||||
(stats?.status === 'zoom-in'
|
||||
? t('cockpit.context.toastZoomToSearch')
|
||||
: t('cockpit.context.toastInstallationsRefreshed')),
|
||||
? t('Zoom in to search mapped installations')
|
||||
: t('Nearby installations refreshed')),
|
||||
);
|
||||
return true;
|
||||
}, t('cockpit.context.toastInstallationsRefreshFailed')).finally(() => {
|
||||
if (this.destroyed) return;
|
||||
button.setAttribute('aria-disabled', 'false');
|
||||
button.setAttribute('aria-busy', 'false');
|
||||
});
|
||||
}, t('Nearby installations could not be refreshed; try again')).finally(
|
||||
() => {
|
||||
if (this.destroyed) return;
|
||||
button.setAttribute('aria-disabled', 'false');
|
||||
button.setAttribute('aria-busy', 'false');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export function _handleContextLayerChange(change) {
|
||||
},
|
||||
{
|
||||
localizedMessage: t(
|
||||
'cockpit.context.toastMissionsCancelRestoreFailed',
|
||||
'Space Missions cancellation could not restore the previous layer state',
|
||||
),
|
||||
},
|
||||
),
|
||||
@@ -94,18 +94,17 @@ export function _handleContextLayerChange(change) {
|
||||
!this._userFacingContextNotificationTokens.has(change.notificationToken)
|
||||
) {
|
||||
this.showToast(
|
||||
change.reason || t('cockpit.context.toastLayerUnavailable'),
|
||||
change.reason ||
|
||||
t('That layer is unavailable in the current Context mode'),
|
||||
);
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
return;
|
||||
}
|
||||
if (change?.type === 'visibility-failed') {
|
||||
const failureMessage = t('cockpit.context.toastLayerLifecycleFailed', {
|
||||
const failureMessage = t('{layerId} could not {action} cleanly', {
|
||||
layerId: change.layerId,
|
||||
action: change.enabled
|
||||
? t('cockpit.context.actionStart')
|
||||
: t('cockpit.context.actionStop'),
|
||||
action: change.enabled ? t('start') : t('stop'),
|
||||
});
|
||||
// A failed direct Context-shell START has already had its siblings
|
||||
// cleared by the visibility guard. Wait outside the synchronous manager
|
||||
|
||||
@@ -125,12 +125,15 @@ export function connectContextManager(manager) {
|
||||
restoreError,
|
||||
);
|
||||
}
|
||||
return t('cockpit.context.toastStartBlocked', {
|
||||
mode:
|
||||
entryMode === 'space-missions'
|
||||
? t('cockpit.context.modeSpaceMissions')
|
||||
: t('cockpit.context.modeContext'),
|
||||
});
|
||||
return t(
|
||||
'{mode} could not start because another layer did not stop cleanly',
|
||||
{
|
||||
mode:
|
||||
entryMode === 'space-missions'
|
||||
? t('Space Missions')
|
||||
: t('Context'),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (ownsNotificationToken) {
|
||||
this._userFacingContextNotificationTokens.delete(
|
||||
|
||||
+54
-56
@@ -5,49 +5,45 @@ export { layerFeedState } from '../data/feedState.js';
|
||||
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
|
||||
import { keySetupRequirementEnvVars } from '../keySetupCore.mjs';
|
||||
import { createWeatherPanel } from './weatherPanel.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
|
||||
// Feed-state enum VALUES are machine identifiers (layerFeedState contract, QA
|
||||
// hooks, CSS feed-* classes); only the final label value translates, at this
|
||||
// presentation boundary. See docs/TRANSLATORS.md keep-English boundary.
|
||||
const FEED_STATE_LABEL_KEYS = Object.freeze({
|
||||
nominal: 'layers.status.on',
|
||||
loading: 'layers.status.loading',
|
||||
degraded: 'layers.status.degraded',
|
||||
stale: 'layers.status.stale',
|
||||
partial: 'layers.status.partial',
|
||||
fallback: 'layers.status.fallback',
|
||||
unavailable: 'layers.status.unavailable',
|
||||
nominal: msg('ON'),
|
||||
loading: msg('LOADING'),
|
||||
degraded: msg('DEGRADED'),
|
||||
stale: msg('STALE'),
|
||||
partial: msg('PARTIAL'),
|
||||
fallback: msg('FALLBACK'),
|
||||
unavailable: msg('UNAVAILABLE'),
|
||||
});
|
||||
|
||||
// Layer display names, localized at the same presentation boundary. Registry
|
||||
// ids stay English; a layer without an entry here renders its module name,
|
||||
// falling back to the English panel override when one exists.
|
||||
const LAYER_NAME_KEYS = Object.freeze({
|
||||
flights: 'layers.name.liveFlights',
|
||||
military: 'layers.name.militaryFlights',
|
||||
earthquakes: 'layers.name.earthquakes',
|
||||
satellites: 'layers.name.satellites',
|
||||
'rocket-launches': 'layers.name.rocketLaunches',
|
||||
traffic: 'layers.name.traffic',
|
||||
cctv: 'layers.name.cctv',
|
||||
radio: 'layers.name.radio',
|
||||
bikeshare: 'layers.name.bikeshare',
|
||||
'ais-live-vessels': 'layers.name.aisVessels',
|
||||
'military-installations': 'layers.name.installations',
|
||||
'military-awareness': 'layers.name.globalContext',
|
||||
'local-datacenters': 'layers.name.datacenters',
|
||||
'local-dams': 'layers.name.dams',
|
||||
'telegeography-submarine-cables': 'layers.name.submarineCables',
|
||||
'local-firms': 'layers.name.fires',
|
||||
flights: msg('Live Flights'),
|
||||
military: msg('Military Flights'),
|
||||
earthquakes: msg('Earthquakes (24h)'),
|
||||
satellites: msg('Satellites'),
|
||||
'rocket-launches': msg('Space Missions (30d)'),
|
||||
traffic: msg('Street Traffic'),
|
||||
cctv: msg('CCTV'),
|
||||
radio: msg('Radio'),
|
||||
bikeshare: msg('Bikeshare'),
|
||||
'ais-live-vessels': msg('Live AIS Vessels'),
|
||||
'military-installations': msg('Mapped Installations'),
|
||||
'military-awareness': msg('Global Context'),
|
||||
'local-datacenters': msg('Datacenters'),
|
||||
'local-dams': msg('Dams'),
|
||||
'telegeography-submarine-cables': msg('Submarine Cables'),
|
||||
'local-firms': msg('FIRMS Active Fires'),
|
||||
});
|
||||
|
||||
function lifecycleStateLabel(lifecycleState) {
|
||||
return t(
|
||||
lifecycleState === 'enabling'
|
||||
? 'layers.status.enabling'
|
||||
: 'layers.status.disabling',
|
||||
);
|
||||
return t(lifecycleState === 'enabling' ? msg('ENABLING') : msg('DISABLING'));
|
||||
}
|
||||
const FEED_STATE_LABELS = Object.freeze({
|
||||
nominal: 'ON',
|
||||
@@ -149,7 +145,9 @@ export function layerKeyRequirementTooltip(layer = {}) {
|
||||
const envVars = requiresKeyId
|
||||
? keySetupRequirementEnvVars(requiresKeyId)
|
||||
: null;
|
||||
return envVars ? t('setup.keySetup.requirement', { envVars }) : '';
|
||||
return envVars
|
||||
? t('Needs {envVars} — add it in Provider Settings', { envVars })
|
||||
: '';
|
||||
}
|
||||
|
||||
/** Layer row presentation over supplied state and actions; no layer imports. */
|
||||
@@ -575,26 +573,29 @@ export class LayerPanel {
|
||||
const lifecycleState =
|
||||
layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
|
||||
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
|
||||
return t('layers.meta.transitioning', {
|
||||
return t('{state} · {source}', {
|
||||
state: lifecycleStateLabel(lifecycleState),
|
||||
source,
|
||||
});
|
||||
}
|
||||
if (layer.lifecycleUncertain) {
|
||||
return t('layers.meta.uncertainLifecycle', { source });
|
||||
return t(
|
||||
'UNCERTAIN · {source} · lifecycle state requires reconciliation',
|
||||
{ source },
|
||||
);
|
||||
}
|
||||
const presentedError =
|
||||
stats.error || stats.lastError || stats.managerRefreshError;
|
||||
if (presentedError) {
|
||||
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
|
||||
return t('layers.meta.stateSourceRetry', {
|
||||
return t('{state} · {source} · {detail} · retry {seconds}s', {
|
||||
state: stateLabel,
|
||||
source,
|
||||
detail: presentedError,
|
||||
seconds: stats.retryInSec,
|
||||
});
|
||||
}
|
||||
return t('layers.meta.stateSourceDetail', {
|
||||
return t('{state} · {source} · {detail}', {
|
||||
state: stateLabel,
|
||||
source,
|
||||
detail: presentedError,
|
||||
@@ -609,22 +610,20 @@ export class LayerPanel {
|
||||
) {
|
||||
return `${source} · ${stats.statusMessage.trim()}`;
|
||||
}
|
||||
const ago = stats.lastUpdate
|
||||
? this._timeAgo(stats.lastUpdate)
|
||||
: t('layers.meta.never');
|
||||
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : t('never');
|
||||
if (stats.loading) {
|
||||
const loadingLabel =
|
||||
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
|
||||
? stats.loadingLabel.trim()
|
||||
: t('layers.meta.loading');
|
||||
return t('layers.meta.sourceLoading', { source, loadingLabel });
|
||||
: t('loading...');
|
||||
return t('{source} · {loadingLabel}', { source, loadingLabel });
|
||||
}
|
||||
if (feedState === 'fallback') {
|
||||
const detail =
|
||||
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
|
||||
? stats.loadingLabel.trim()
|
||||
: stats.coverage || ago;
|
||||
return t('layers.meta.stateSourceDetail', {
|
||||
return t('{state} · {source} · {detail}', {
|
||||
state: stateLabel,
|
||||
source,
|
||||
detail,
|
||||
@@ -637,12 +636,12 @@ export class LayerPanel {
|
||||
Number.isInteger(rawRowCount) &&
|
||||
acceptedRowCount >= 0 &&
|
||||
rawRowCount > acceptedRowCount
|
||||
? t('layers.meta.partialCounts', {
|
||||
? t('{accepted} of {raw} records accepted', {
|
||||
accepted: acceptedRowCount,
|
||||
raw: rawRowCount,
|
||||
})
|
||||
: t('layers.meta.partialIncomplete');
|
||||
return t('layers.meta.partialDetail', {
|
||||
: t('incomplete snapshot');
|
||||
return t('{state} · {source} · {detail} · {ago}', {
|
||||
state: stateLabel,
|
||||
source,
|
||||
detail,
|
||||
@@ -651,26 +650,26 @@ export class LayerPanel {
|
||||
}
|
||||
if (feedState === 'stale') {
|
||||
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
|
||||
return t('layers.meta.stateSourceAgoRetry', {
|
||||
return t('{state} · {source} · {ago} · retrying in {seconds}s', {
|
||||
state: stateLabel,
|
||||
source,
|
||||
ago,
|
||||
seconds: stats.retryInSec,
|
||||
});
|
||||
}
|
||||
return t('layers.meta.stateSourceAgo', {
|
||||
return t('{state} · {source} · {ago}', {
|
||||
state: stateLabel,
|
||||
source,
|
||||
ago,
|
||||
});
|
||||
}
|
||||
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
|
||||
return t('layers.meta.sourceLoading', {
|
||||
return t('{source} · {loadingLabel}', {
|
||||
source,
|
||||
loadingLabel: stats.loadingLabel.trim(),
|
||||
});
|
||||
}
|
||||
return t('layers.meta.sourceAgo', { source, ago });
|
||||
return t('{source} · {ago}', { source, ago });
|
||||
}
|
||||
|
||||
_syncToggleButton(button, layer) {
|
||||
@@ -712,18 +711,18 @@ export class LayerPanel {
|
||||
button.textContent = transitioning
|
||||
? lifecycleStateLabel(layer.lifecycleState)
|
||||
: uncertain
|
||||
? t('layers.status.uncertain')
|
||||
? t('UNCERTAIN')
|
||||
: layer.enabled || unavailable
|
||||
? t(FEED_STATE_LABEL_KEYS[feedState])
|
||||
: t('layers.status.off');
|
||||
: t('OFF');
|
||||
const keyGuidance = unavailable
|
||||
? String(layer.stats.error || t('layers.meta.dataSourceUnavailable'))
|
||||
? String(layer.stats.error || t('Data source unavailable'))
|
||||
: layerKeyRequirementTooltip(layer);
|
||||
// Name the missing key on the control itself: a row reading KEY REQUIRED
|
||||
// without saying WHICH key leaves a dead control and no next step. Empty
|
||||
// when the layer needs no key, or already has one.
|
||||
button.title = keyGuidance;
|
||||
const ariaLabel = t('layers.meta.toggleAriaLabel', {
|
||||
const ariaLabel = t('{name}: {state}', {
|
||||
name: panelLabel(layer),
|
||||
state: button.textContent,
|
||||
});
|
||||
@@ -740,10 +739,9 @@ export class LayerPanel {
|
||||
|
||||
_timeAgo(timestamp) {
|
||||
const diff = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (diff < 5) return t('layers.meta.justNow');
|
||||
if (diff < 60) return t('layers.meta.secondsAgo', { count: diff });
|
||||
if (diff < 3600)
|
||||
return t('layers.meta.minutesAgo', { count: Math.floor(diff / 60) });
|
||||
return t('layers.meta.hoursAgo', { count: Math.floor(diff / 3600) });
|
||||
if (diff < 5) return t('just now');
|
||||
if (diff < 60) return t('{count}s ago', { count: diff });
|
||||
if (diff < 3600) return t('{count}m ago', { count: Math.floor(diff / 60) });
|
||||
return t('{count}h ago', { count: Math.floor(diff / 3600) });
|
||||
}
|
||||
}
|
||||
@@ -151,16 +151,12 @@ export class LocationControls {
|
||||
// localized static readout (resolved place names stay verbatim data).
|
||||
this.elements.statusCity.textContent =
|
||||
lines.city === LOCATION_MINI_EMPTY.city
|
||||
? t('cockpit.location.miniCityInitial')
|
||||
? t('📍 Location: --')
|
||||
: lines.city;
|
||||
if (lines.poi === LOCATION_MINI_EMPTY.poi) {
|
||||
this.elements.statusPoi.textContent = t(
|
||||
'cockpit.location.miniPoiInitial',
|
||||
);
|
||||
this.elements.statusPoi.textContent = t('Landmark: --');
|
||||
} else if (lines.poi === LOCATION_MINI_SEARCHED_PLACEHOLDER.poi) {
|
||||
this.elements.statusPoi.textContent = t(
|
||||
'cockpit.location.miniSearchedPlaceholder',
|
||||
);
|
||||
this.elements.statusPoi.textContent = t('Searched location');
|
||||
} else {
|
||||
this.elements.statusPoi.textContent = lines.poi;
|
||||
}
|
||||
@@ -173,7 +169,7 @@ export class LocationControls {
|
||||
const icon = this.doc.createElement('span');
|
||||
icon.className = 'orbit-icon';
|
||||
icon.textContent = '↻';
|
||||
this.orbitIndicator.append(icon, ` ${t('cockpit.location.orbitLabel')}`);
|
||||
this.orbitIndicator.append(icon, ` ${t('ORBIT')}`);
|
||||
this.doc.body.appendChild(this.orbitIndicator);
|
||||
}
|
||||
return this.orbitIndicator;
|
||||
|
||||
@@ -89,9 +89,8 @@ export class LocationNavigation {
|
||||
this._collapsePOIRow();
|
||||
this._updateLocationMiniStatus();
|
||||
} else if (change.type === 'missing')
|
||||
this._showToast(t('cockpit.location.toastNotFound'));
|
||||
else if (change.type === 'failed')
|
||||
this._showToast(t('cockpit.location.toastSearchFailed'));
|
||||
this._showToast(t('Location not found'));
|
||||
else if (change.type === 'failed') this._showToast(t('Search failed'));
|
||||
else if (change.type === 'settled')
|
||||
this._settleLocationSearchUi(change.generation);
|
||||
else if (
|
||||
@@ -280,7 +279,7 @@ export class LocationNavigation {
|
||||
|
||||
_toggleOrbit() {
|
||||
if (!this._currentTarget) {
|
||||
this._showToast(t('cockpit.location.toastFlyToPoiFirst'));
|
||||
this._showToast(t('Fly to a POI first'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -382,11 +381,11 @@ export class LocationNavigation {
|
||||
};
|
||||
this._resetGlobeBtn?.setAttribute(
|
||||
'aria-label',
|
||||
t('cockpit.hud.resetGlobeAria'),
|
||||
t('Reset to full globe view'),
|
||||
);
|
||||
this._cockpitResetGlobeBtn?.setAttribute(
|
||||
'aria-label',
|
||||
t('cockpit.hud.resetGlobeCockpitAria'),
|
||||
t('Reset cockpit to full globe view'),
|
||||
);
|
||||
this._globeResetPromise = null;
|
||||
this._cancelGlobeReset = null;
|
||||
@@ -402,11 +401,11 @@ export class LocationNavigation {
|
||||
}, 4200);
|
||||
this._resetGlobeBtn?.setAttribute(
|
||||
'aria-label',
|
||||
t('cockpit.hud.resettingGlobeAria'),
|
||||
t('Resetting to full globe view'),
|
||||
);
|
||||
this._cockpitResetGlobeBtn?.setAttribute(
|
||||
'aria-label',
|
||||
t('cockpit.hud.resettingGlobeCockpitAria'),
|
||||
t('Resetting cockpit to full globe view'),
|
||||
);
|
||||
const target = flyToGlobeView(this.viewer, {
|
||||
onComplete: () => finish(false),
|
||||
|
||||
@@ -29,9 +29,7 @@ export function createMapSourceControls({
|
||||
statusElement.textContent =
|
||||
state.status === 'switching'
|
||||
? '...'
|
||||
: stack?.shortLabel ||
|
||||
stack?.label ||
|
||||
t('cockpit.presets.mapStackFallback');
|
||||
: stack?.shortLabel || stack?.label || t('MAP');
|
||||
statusElement.classList.toggle('warn', !!state.lastError);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-12
@@ -1,5 +1,5 @@
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { displayPanelScroller } from './displayPanelScroll.js';
|
||||
import { PanelPositionControls } from './panelPositionControls.js';
|
||||
import { PanelLayoutController } from './panelLayoutController.js';
|
||||
@@ -311,26 +311,24 @@ export class PanelChrome {
|
||||
const panelName =
|
||||
panelEl
|
||||
.querySelector('.panel-title, .pp-header-label')
|
||||
?.textContent?.trim() || t('cockpit.panel.fallbackName');
|
||||
?.textContent?.trim() || t('panel');
|
||||
const titleKey = collapsed
|
||||
? 'cockpit.panel.expandTitle'
|
||||
: 'cockpit.panel.collapseTitle';
|
||||
? msg('Expand {name}')
|
||||
: msg('Collapse {name}');
|
||||
btn.title = t(titleKey, { name: panelName });
|
||||
btn.setAttribute('aria-label', t(titleKey, { name: panelName }));
|
||||
if (panelEl.id === 'radio-panel') {
|
||||
// Expand state reuses the layers.* keys seeded on the static button;
|
||||
// the collapse state is runtime-only, so it owns cockpit.* keys.
|
||||
btn.title = t(
|
||||
collapsed
|
||||
? 'layers.radio.expandTitle'
|
||||
: 'cockpit.panel.radioCollapseTitle',
|
||||
collapsed ? msg('Expand Radio') : msg('Collapse Radio'),
|
||||
);
|
||||
btn.setAttribute(
|
||||
'aria-label',
|
||||
t(
|
||||
collapsed
|
||||
? 'layers.radio.expandAriaLabel'
|
||||
: 'cockpit.panel.radioCollapseAria',
|
||||
? msg('Expand Radio section')
|
||||
: msg('Collapse Radio section'),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -342,10 +340,10 @@ export class PanelChrome {
|
||||
const panelName =
|
||||
panelEl
|
||||
.querySelector('.panel-title, .location-toolbar-label')
|
||||
?.textContent?.trim() || t('cockpit.panel.fallbackName');
|
||||
?.textContent?.trim() || t('panel');
|
||||
const titleKey = collapsed
|
||||
? 'cockpit.panel.expandTitle'
|
||||
: 'cockpit.panel.collapseTitle';
|
||||
? msg('Expand {name}')
|
||||
: msg('Collapse {name}');
|
||||
dockToggle.setAttribute('aria-expanded', String(!collapsed));
|
||||
dockToggle.setAttribute('aria-label', t(titleKey, { name: panelName }));
|
||||
dockToggle.title = t(titleKey, { name: panelName });
|
||||
|
||||
@@ -220,7 +220,9 @@ export class PanelPositionControls {
|
||||
key.startsWith('godsEyeView.v6.panelPos.'),
|
||||
);
|
||||
if (hadOldPositions) {
|
||||
this._showToast(t('cockpit.panel.layoutResetToast'));
|
||||
this._showToast(
|
||||
t('Panel layout updated — positions reset to new defaults'),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// storage unavailable
|
||||
|
||||
+17
-21
@@ -1,4 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import {
|
||||
radioTunerSlot,
|
||||
radioTunerCommitSlot,
|
||||
@@ -53,14 +53,14 @@ export function bindRadioControls() {
|
||||
'aria-label',
|
||||
t(
|
||||
displayOpen
|
||||
? 'cockpit.utility.displayToggleCollapseAriaLabel'
|
||||
: 'cockpit.utility.displayToggleAriaLabel',
|
||||
? msg('Collapse Cockpit display options')
|
||||
: msg('Expand Cockpit display options'),
|
||||
),
|
||||
);
|
||||
this._cockpitDisplayToggleBtn.title = t(
|
||||
displayOpen
|
||||
? 'cockpit.utility.displayToggleCollapseAriaLabel'
|
||||
: 'cockpit.utility.displayToggleAriaLabel',
|
||||
? msg('Collapse Cockpit display options')
|
||||
: msg('Expand Cockpit display options'),
|
||||
);
|
||||
}
|
||||
if (this._cockpitRadioToggleBtn) {
|
||||
@@ -69,14 +69,14 @@ export function bindRadioControls() {
|
||||
'aria-label',
|
||||
t(
|
||||
radioOpen
|
||||
? 'cockpit.utility.radioToggleCollapseAriaLabel'
|
||||
: 'cockpit.utility.radioToggleAriaLabel',
|
||||
? msg('Collapse Cockpit Radio controls')
|
||||
: msg('Expand Cockpit Radio controls'),
|
||||
),
|
||||
);
|
||||
this._cockpitRadioToggleBtn.title = t(
|
||||
radioOpen
|
||||
? 'cockpit.utility.radioToggleCollapseAriaLabel'
|
||||
: 'cockpit.utility.radioToggleAriaLabel',
|
||||
? msg('Collapse Cockpit Radio controls')
|
||||
: msg('Expand Cockpit Radio controls'),
|
||||
);
|
||||
}
|
||||
if (!expanded && returnFocus) {
|
||||
@@ -155,21 +155,21 @@ export function bindRadioControls() {
|
||||
if (this._radioTunerValue) {
|
||||
this._radioTunerValue.textContent = station
|
||||
? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}`
|
||||
: t('cockpit.radio.tunerNoStations');
|
||||
: t('NO STATIONS');
|
||||
}
|
||||
if (this._radioTunerStation)
|
||||
this._radioTunerStation.textContent =
|
||||
station?.name || t('layers.radio.noStationAvailable');
|
||||
station?.name || t('NO STATION AVAILABLE');
|
||||
if (this._radioTunerSlider) {
|
||||
this._radioTunerSlider.setAttribute(
|
||||
'aria-valuetext',
|
||||
station
|
||||
? t('cockpit.radio.tunerStationAria', {
|
||||
? t('{name}, station {index} of {total}', {
|
||||
name: station.name,
|
||||
index: slot.stationIndex + 1,
|
||||
total: this._radioTunerStations.length,
|
||||
})
|
||||
: t('cockpit.radio.tunerNoStationAria'),
|
||||
: t('No station available'),
|
||||
);
|
||||
}
|
||||
if (syncStatic)
|
||||
@@ -320,11 +320,9 @@ export function bindRadioControls() {
|
||||
this._radioTunerBandPinnedForNavigation = false;
|
||||
if (result.reason === 'station-unavailable') {
|
||||
if (this._radioTunerValue)
|
||||
this._radioTunerValue.textContent = t('cockpit.radio.tunerOffAir');
|
||||
this._radioTunerValue.textContent = t('OFF AIR');
|
||||
if (this._radioTunerStation)
|
||||
this._radioTunerStation.textContent = t(
|
||||
'cockpit.radio.tunerStationUnavailable',
|
||||
);
|
||||
this._radioTunerStation.textContent = t('STATION UNAVAILABLE');
|
||||
this._radioTunerSlider?.setAttribute(
|
||||
'aria-valuetext',
|
||||
'Station unavailable after directory refresh',
|
||||
@@ -367,10 +365,8 @@ export function bindRadioControls() {
|
||||
// (identical value under 'en').
|
||||
`Radio could not ${enabling ? 'start' : 'stop'} cleanly`,
|
||||
{
|
||||
localizedMessage: t('cockpit.radio.toastLifecycleFailed', {
|
||||
action: enabling
|
||||
? t('cockpit.context.actionStart')
|
||||
: t('cockpit.context.actionStop'),
|
||||
localizedMessage: t('Radio could not {action} cleanly', {
|
||||
action: enabling ? t('start') : t('stop'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { bindRadioControls } from './radioBindings.js';
|
||||
import { renderRadioState } from './radioPresentation.js';
|
||||
|
||||
@@ -201,8 +201,8 @@ export class RadioControls {
|
||||
);
|
||||
const launcherLabel = t(
|
||||
compactOpen
|
||||
? 'cockpit.context.radioToggleCloseAriaLabel'
|
||||
: 'cockpit.context.radioToggleAriaLabel',
|
||||
? msg('Close compact Radio controls')
|
||||
: msg('Open compact Radio controls'),
|
||||
);
|
||||
this._contextRadioToggleBtn.setAttribute('aria-label', launcherLabel);
|
||||
this._contextRadioToggleBtn.title = launcherLabel;
|
||||
|
||||
+49
-58
@@ -35,29 +35,27 @@ export function renderRadioState(state) {
|
||||
if (this._radioLayerState) {
|
||||
// Lifecycle enum values map to status-chip keys; counts stay machine.
|
||||
const lifecycleLabel =
|
||||
lifecycleState === 'enabling'
|
||||
? t('layers.status.enabling')
|
||||
: t('layers.status.disabling');
|
||||
lifecycleState === 'enabling' ? t('ENABLING') : t('DISABLING');
|
||||
this._radioLayerState.textContent = transitioning
|
||||
? lifecycleLabel
|
||||
: uncertain
|
||||
? t('layers.status.uncertain')
|
||||
? t('UNCERTAIN')
|
||||
: state.loading
|
||||
? t('cockpit.radio.stateSync')
|
||||
? t('SYNC')
|
||||
: enabled
|
||||
? `${state.filteredCount}/${state.stationCount}`
|
||||
: t('layers.status.off');
|
||||
: t('OFF');
|
||||
}
|
||||
const enableLabel = uncertain
|
||||
? t('layers.radio.reconcile')
|
||||
? t('RECONCILE')
|
||||
: enabled
|
||||
? t('layers.radio.disable')
|
||||
: t('layers.radio.enable');
|
||||
? t('DISABLE')
|
||||
: t('ENABLE');
|
||||
const enableAria = uncertain
|
||||
? t('layers.radio.reconcileAria')
|
||||
? t('Reconcile Radio — lifecycle uncertain')
|
||||
: enabled
|
||||
? t('layers.radio.disableAria')
|
||||
: t('layers.radio.enableAria');
|
||||
? t('Disable Radio')
|
||||
: t('Enable Radio');
|
||||
if (this._radioEnableBtn) {
|
||||
this._radioEnableBtn.classList.toggle('active', enabled);
|
||||
this._radioEnableBtn.setAttribute('aria-pressed', String(enabled));
|
||||
@@ -146,8 +144,8 @@ export function renderRadioState(state) {
|
||||
);
|
||||
this._radioTunerBandLabel.textContent =
|
||||
state.filter === 'all'
|
||||
? t('layers.radio.bandLabel')
|
||||
: t('cockpit.radio.tunerCategoryBand', {
|
||||
? t('DIRECTORY BAND')
|
||||
: t('{category} BAND', {
|
||||
category: String(
|
||||
activeCategory?.label || state.filter,
|
||||
).toUpperCase(),
|
||||
@@ -170,7 +168,7 @@ export function renderRadioState(state) {
|
||||
|
||||
if (this._radioStationName)
|
||||
this._radioStationName.textContent =
|
||||
selected?.name || t('layers.radio.noStation');
|
||||
selected?.name || t('NO STATION SELECTED');
|
||||
if (this._radioStationMeta) {
|
||||
const place = selected
|
||||
? [selected.state, selected.countryCode].filter(Boolean).join(' · ')
|
||||
@@ -182,15 +180,15 @@ export function renderRadioState(state) {
|
||||
: '';
|
||||
this._radioStationMeta.textContent = selected
|
||||
? [place, signal].filter(Boolean).join(' / ') ||
|
||||
t('layers.radio.meta.metadataOnly')
|
||||
t('Directory metadata only')
|
||||
: state.loading
|
||||
? t('layers.radio.meta.loading')
|
||||
: t('layers.radio.meta.chooseHint');
|
||||
? t('Loading station directory…')
|
||||
: t('Choose a globe marker or use next.');
|
||||
}
|
||||
if (this._radioStationTags) {
|
||||
const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : [];
|
||||
this._radioStationTags.textContent = tags.length
|
||||
? t('layers.radio.tags', { tags: tags.join(' · ') })
|
||||
? t('TAGS · {tags}', { tags: tags.join(' · ') })
|
||||
: '';
|
||||
}
|
||||
if (this._radioStationHomepage) {
|
||||
@@ -213,14 +211,12 @@ export function renderRadioState(state) {
|
||||
if (this._cockpitRadioNextBtn)
|
||||
this._cockpitRadioNextBtn.disabled = !interactive || !hasStations;
|
||||
const playbackAction = activePlayback
|
||||
? t('cockpit.radio.actionPause')
|
||||
? t('Pause')
|
||||
: state.audioState === 'paused'
|
||||
? t('cockpit.radio.actionResume')
|
||||
: t('cockpit.radio.actionPlay');
|
||||
const playTargetWord = selected
|
||||
? t('cockpit.radio.targetSelected')
|
||||
: t('cockpit.radio.targetNearest');
|
||||
const playAria = t('cockpit.radio.playStateAria', {
|
||||
? t('Resume')
|
||||
: t('Play');
|
||||
const playTargetWord = selected ? t('selected') : t('nearest');
|
||||
const playAria = t('{action} {target} radio station', {
|
||||
action: playbackAction,
|
||||
target: playTargetWord,
|
||||
});
|
||||
@@ -277,65 +273,60 @@ export function renderRadioState(state) {
|
||||
}
|
||||
if (this._contextRadioMiniStation) {
|
||||
this._contextRadioMiniStation.textContent = uncertain
|
||||
? t('cockpit.radio.miniStateUncertain')
|
||||
? t('RADIO STATE UNCERTAIN')
|
||||
: selected?.name ||
|
||||
(state.loading
|
||||
? t('cockpit.radio.miniSyncingDirectory')
|
||||
: t('cockpit.context.radioMiniReady'));
|
||||
(state.loading ? t('SYNCING DIRECTORY') : t('RADIO READY'));
|
||||
}
|
||||
if (this._cockpitRadioStation) {
|
||||
this._cockpitRadioStation.textContent = uncertain
|
||||
? t('layers.status.uncertain')
|
||||
: selected?.name ||
|
||||
(state.loading
|
||||
? t('cockpit.radio.stationSyncing')
|
||||
: t('cockpit.radio.station.ready'));
|
||||
? t('UNCERTAIN')
|
||||
: selected?.name || (state.loading ? t('SYNCING') : t('READY'));
|
||||
}
|
||||
if (this._radioPlaybackState) {
|
||||
const stationName = selected?.name || t('cockpit.radio.stationFallback');
|
||||
const stationName = selected?.name || t('station');
|
||||
const catalogSuffix = state.degraded
|
||||
? state.stale
|
||||
? t('layers.radio.state.staleDegradedDirectory')
|
||||
: t('layers.radio.state.degradedDirectory')
|
||||
? t(' · stale/degraded directory')
|
||||
: t(' · degraded directory')
|
||||
: state.stale
|
||||
? t('layers.radio.state.staleDirectory')
|
||||
? t(' · stale directory')
|
||||
: '';
|
||||
const outsideFilter =
|
||||
selected && state.selectedIndex < 0
|
||||
? t('layers.radio.state.outsideFilter')
|
||||
: '';
|
||||
selected && state.selectedIndex < 0 ? t(' · outside current filter') : '';
|
||||
const messages = {
|
||||
stopped: enabled
|
||||
? t('layers.radio.state.ready')
|
||||
: t('layers.radio.playbackOff'),
|
||||
loading: t('layers.radio.state.loading'),
|
||||
buffering: t('layers.radio.state.buffering'),
|
||||
playing: t('layers.radio.state.playing', { station: stationName }),
|
||||
paused: t('layers.radio.state.paused', { station: stationName }),
|
||||
error: state.audioError || t('layers.radio.state.error'),
|
||||
? t('Ready — playback starts only from your action')
|
||||
: t('Radio off'),
|
||||
loading: t('Connecting directly to broadcaster…'),
|
||||
buffering: t('Buffering broadcaster stream…'),
|
||||
playing: t('Playing {station}', { station: stationName }),
|
||||
paused: t('Paused {station}', { station: stationName }),
|
||||
error: state.audioError || t('Broadcaster stream unavailable'),
|
||||
};
|
||||
const voiceSuffix = state.voiceDucked
|
||||
? t('layers.radio.state.voiceMuted')
|
||||
? t(' · muted during voice interaction')
|
||||
: state.voiceRestoring
|
||||
? t('layers.radio.state.voiceRestoring')
|
||||
? t(' · restoring volume after voice')
|
||||
: '';
|
||||
const tuningSuffix = state.tuningAwaitingStationId
|
||||
? state.audioState === 'error'
|
||||
? t('layers.radio.state.staticNoAudio')
|
||||
: t('layers.radio.state.tuningStatic')
|
||||
? t(' · static indicates no broadcaster audio')
|
||||
: t(' · tuning static until broadcaster starts')
|
||||
: '';
|
||||
const unavailable = state.tuningUnavailableStationId
|
||||
? t('layers.radio.state.stationUnavailable')
|
||||
? t(
|
||||
'Station unavailable after directory refresh — choose another channel',
|
||||
)
|
||||
: null;
|
||||
const lifecycleMessage = transitioning
|
||||
? lifecycleState === 'enabling'
|
||||
? t('layers.radio.state.enabling')
|
||||
: t('layers.radio.state.disabling')
|
||||
? t('Radio is enabling…')
|
||||
: t('Radio is disabling…')
|
||||
: null;
|
||||
const uncertainMessage = uncertain
|
||||
? t('layers.radio.state.uncertain')
|
||||
? t('Radio lifecycle is uncertain — use Enable or Disable to reconcile')
|
||||
: null;
|
||||
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || t('cockpit.radio.playbackReadyFallback')}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
|
||||
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || t('Ready')}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
|
||||
this._radioPlaybackState.classList.toggle(
|
||||
'error',
|
||||
Boolean(
|
||||
|
||||
@@ -58,7 +58,7 @@ export class SceneControls {
|
||||
if (!this.destroyed) elements.file.value = '';
|
||||
});
|
||||
if (!subscribe) {
|
||||
this.updateStatus(t('setup.scenes.statusReady'));
|
||||
this.updateStatus(t('Ready'));
|
||||
this.setProgress(0);
|
||||
this.setButtons(false);
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export class SceneControls {
|
||||
const generation = ++this.actionGeneration;
|
||||
const failed = () => {
|
||||
if (!this.destroyed && generation === this.actionGeneration)
|
||||
this.updateStatus(t('setup.scenes.status.actionFailed'));
|
||||
this.updateStatus(t('Scene action failed'));
|
||||
};
|
||||
try {
|
||||
const result = this.actions[action](...args);
|
||||
@@ -163,9 +163,7 @@ export class SceneControls {
|
||||
const shot = scene?.shots.find((item) => item.id === shotId);
|
||||
if (
|
||||
shot &&
|
||||
window.confirm(
|
||||
t('setup.scenes.status.deleteShotConfirm', { shot: shot.title }),
|
||||
)
|
||||
window.confirm(t('Delete shot "{shot}"?', { shot: shot.title }))
|
||||
)
|
||||
this.run('deleteShot', sceneId, shotId);
|
||||
}
|
||||
|
||||
@@ -189,12 +189,10 @@ export class ShareRestoration {
|
||||
if (result.classification === 'pending') {
|
||||
this._shareTrackingNoticeGeneration += 1;
|
||||
this._shareTrackingAcquiringKey = trackingKey;
|
||||
this.showStatus(t('cockpit.status.acquiring'), {
|
||||
this.showStatus(t('ACQUIRING'), {
|
||||
state: 'acquiring',
|
||||
detail: t('cockpit.status.sharedSubjectDetail', {
|
||||
subject: String(
|
||||
result.label || t('cockpit.status.subjectFallback'),
|
||||
).toUpperCase(),
|
||||
detail: t('SHARED {subject}', {
|
||||
subject: String(result.label || t('entity')).toUpperCase(),
|
||||
}),
|
||||
persistent: true,
|
||||
});
|
||||
@@ -219,13 +217,15 @@ export class ShareRestoration {
|
||||
const noticeGeneration = ownsAcquiringNotice
|
||||
? this._shareTrackingNoticeGeneration
|
||||
: ++this._shareTrackingNoticeGeneration;
|
||||
const subject = result.label || t('cockpit.status.subjectFallback');
|
||||
const subject = result.label || t('entity');
|
||||
const message =
|
||||
result.classification === 'expired'
|
||||
? t('cockpit.status.sharedFollowExpired', { subject })
|
||||
? t('Shared {subject} follow expired', { subject })
|
||||
: result.classification === 'source-unavailable'
|
||||
? t('cockpit.status.sharedRestoreFailed', { subject })
|
||||
: t('cockpit.status.sharedUnavailable', { subject });
|
||||
? t('Shared {subject} could not be restored — feed unavailable', {
|
||||
subject,
|
||||
})
|
||||
: t('Shared {subject} is unavailable', { subject });
|
||||
const showAfterStartupCover = () => {
|
||||
this._lifetime.frame(() => {
|
||||
if (
|
||||
|
||||
+14
-14
@@ -9,7 +9,7 @@ import {
|
||||
reduceTrafficSyncFeedback,
|
||||
} from '../loadingFeedback.js';
|
||||
import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
|
||||
// loadingFeedback.js composes the shared status copy inside the portable
|
||||
// node/browser graph (the server-side HUD summary reads the same modules), so
|
||||
@@ -17,17 +17,17 @@ import { t } from '../i18n/index.js';
|
||||
// the closed label vocabulary to the catalog, and pass anything unrecognized
|
||||
// (a live provider error) through verbatim (docs/TRANSLATORS.md).
|
||||
const STATUS_LABEL_KEYS = Object.freeze({
|
||||
'LOADING LIVE DATA': 'shell.status.loadingLiveData',
|
||||
'LOAD COMPLETE': 'shell.status.loadComplete',
|
||||
'LOAD CANCELLED': 'shell.status.loadCancelled',
|
||||
'LOAD FAILED': 'shell.status.loadFailed',
|
||||
'LIVE DATA OFF': 'shell.status.liveDataOff',
|
||||
'MAPPED SITES LOADED': 'shell.status.mappedSitesLoaded',
|
||||
'RETRYING MAPPED SITES': 'shell.status.retryingMappedSites',
|
||||
'FETCHING MAPPED SITES': 'shell.status.fetchingMappedSites',
|
||||
'TURNING OFF LIVE DATA': 'shell.status.turningOffLiveData',
|
||||
'REFRESHING LIVE DATA': 'shell.status.refreshingLiveData',
|
||||
'syncing road network': 'shell.status.trafficSyncing',
|
||||
'LOADING LIVE DATA': msg('LOADING LIVE DATA'),
|
||||
'LOAD COMPLETE': msg('LOAD COMPLETE'),
|
||||
'LOAD CANCELLED': msg('LOAD CANCELLED'),
|
||||
'LOAD FAILED': msg('LOAD FAILED'),
|
||||
'LIVE DATA OFF': msg('LIVE DATA OFF'),
|
||||
'MAPPED SITES LOADED': msg('MAPPED SITES LOADED'),
|
||||
'RETRYING MAPPED SITES': msg('RETRYING MAPPED SITES'),
|
||||
'FETCHING MAPPED SITES': msg('FETCHING MAPPED SITES'),
|
||||
'TURNING OFF LIVE DATA': msg('TURNING OFF LIVE DATA'),
|
||||
'REFRESHING LIVE DATA': msg('REFRESHING LIVE DATA'),
|
||||
'syncing road network': msg('syncing road network'),
|
||||
});
|
||||
|
||||
function localizeStatusLabel(label) {
|
||||
@@ -41,8 +41,8 @@ const RETRY_DETAIL_RE = /^(?:ALPR cameras · )?retrying in (\d+)s$/;
|
||||
function localizeStatusDetail(detail) {
|
||||
if (typeof detail !== 'string' || !detail) return detail;
|
||||
const match = detail.match(RETRY_DETAIL_RE);
|
||||
if (match) return t('shell.status.retryingIn', { seconds: match[1] });
|
||||
if (detail.trim() === 'retry pending') return t('shell.status.retryPending');
|
||||
if (match) return t('retrying in {seconds}s', { seconds: match[1] });
|
||||
if (detail.trim() === 'retry pending') return t('retry pending');
|
||||
return detail;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- Aircraft first-person view. It can be entered from the visible view switcher. -->
|
||||
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden data-i18n-aria-label="cockpit.hud.sectionLabel">
|
||||
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden data-i18n-aria-label>
|
||||
<!-- One optical coordinate system: every targeting mark shares this center.
|
||||
Decorative only; telemetry and interactive panels remain separate. -->
|
||||
<svg class="cockpit-frame-lines" viewBox="0 0 1200 720" preserveAspectRatio="none" aria-hidden="true" focusable="false">
|
||||
@@ -61,19 +61,19 @@
|
||||
<span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
|
||||
</div>
|
||||
<div class="cockpit-horizon-guide">
|
||||
<span data-i18n="cockpit.hud.level"></span><small>LEVEL</small><span></span>
|
||||
<span></span><small data-i18n>LEVEL</small><span></span>
|
||||
</div>
|
||||
<div id="cockpit-route-direction" class="cockpit-route-direction" hidden aria-label="Estimated destination direction" data-i18n-aria-label="cockpit.hud.routeDirectionAriaLabel">
|
||||
<div id="cockpit-route-direction" class="cockpit-route-direction" hidden aria-label="Estimated destination direction" data-i18n-aria-label>
|
||||
<svg class="cockpit-route-chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M12 2 4.58 19.93c-.34.83.51 1.63 1.31 1.27L12 18.47l6.11 2.73c.8.36 1.65-.44 1.31-1.27L12 2Z" />
|
||||
</svg>
|
||||
<small id="cockpit-route-direction-label" data-i18n="cockpit.hud.routeDirectionIdle">DEST ---°</small>
|
||||
<small id="cockpit-route-direction-label" data-i18n>DEST ---°</small>
|
||||
</div>
|
||||
<div class="cockpit-visor-status cockpit-visor-status-left" data-i18n="cockpit.hud.visorPlane">OPTICAL PLANE · 01</div>
|
||||
<div class="cockpit-visor-status cockpit-visor-status-right" data-i18n="cockpit.hud.visorLock">VISOR LOCK · ACTIVE</div>
|
||||
<div class="cockpit-visor-status cockpit-visor-status-left" data-i18n>OPTICAL PLANE · 01</div>
|
||||
<div class="cockpit-visor-status cockpit-visor-status-right" data-i18n>VISOR LOCK · ACTIVE</div>
|
||||
</div>
|
||||
<div id="cockpit-speed-rim" class="cockpit-altitude-rim cockpit-speed-rim">
|
||||
<span class="cockpit-altitude-rim-label" data-i18n="cockpit.readout.rimGroundSpeed">GROUND SPEED · KTS</span>
|
||||
<span class="cockpit-altitude-rim-label" data-i18n>GROUND SPEED · KTS</span>
|
||||
<div class="cockpit-altitude-rim-rail"></div>
|
||||
<div class="cockpit-altitude-rim-scale">
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
@@ -92,7 +92,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="cockpit-altitude-rim" class="cockpit-altitude-rim">
|
||||
<span class="cockpit-altitude-rim-label" data-i18n="cockpit.readout.rimAltitude">ALTITUDE · FT</span>
|
||||
<span class="cockpit-altitude-rim-label" data-i18n>ALTITUDE · FT</span>
|
||||
<div class="cockpit-altitude-rim-rail"></div>
|
||||
<div class="cockpit-altitude-rim-scale">
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
@@ -112,36 +112,36 @@
|
||||
</div>
|
||||
<div class="cockpit-topline">
|
||||
<div>
|
||||
<span class="cockpit-kicker" data-i18n="cockpit.hud.firstPerson">FIRST PERSON</span>
|
||||
<span class="cockpit-kicker" data-i18n>FIRST PERSON</span>
|
||||
<strong id="cockpit-callsign">AIRCRAFT</strong>
|
||||
<span id="cockpit-aircraft-meta" class="cockpit-aircraft-meta" data-i18n="cockpit.hud.aircraftMetaInitial">LIVE TRACK · COURSE ALIGNED</span>
|
||||
<span id="cockpit-aircraft-meta" class="cockpit-aircraft-meta" data-i18n>LIVE TRACK · COURSE ALIGNED</span>
|
||||
</div>
|
||||
<div class="cockpit-vision-controls" role="group" aria-label="Cockpit vision style" data-i18n-aria-label="cockpit.hud.visionGroupAriaLabel">
|
||||
<button id="cockpit-vision-previous" class="cockpit-vision-nav" type="button" aria-label="Previous cockpit vision style" title="Previous vision style" data-i18n-aria-label="cockpit.vision.previousAriaLabel" data-i18n-title="cockpit.vision.previousTitle">
|
||||
<div class="cockpit-vision-controls" role="group" aria-label="Cockpit vision style" data-i18n-aria-label>
|
||||
<button id="cockpit-vision-previous" class="cockpit-vision-nav" type="button" aria-label="Previous cockpit vision style" title="Previous vision style" data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
|
||||
<small data-i18n="cockpit.vision.previousLabel">PREV</small>
|
||||
<small data-i18n>PREV</small>
|
||||
</button>
|
||||
<button id="cockpit-vision-current" class="cockpit-vision-current" type="button" data-cockpit-vision="optical" aria-label="Current cockpit vision style: NORMAL. Activate for next style." title="Current style: NORMAL — click for next" data-i18n-aria-label="cockpit.vision.currentAriaLabel" data-i18n-title="cockpit.vision.currentTitle">
|
||||
<small data-i18n="cockpit.vision.current">CURRENT</small>
|
||||
<button id="cockpit-vision-current" class="cockpit-vision-current" type="button" data-cockpit-vision="optical" aria-label="Current cockpit vision style: NORMAL. Activate for next style." title="Current style: NORMAL — click for next" data-i18n-aria-label data-i18n-title>
|
||||
<small data-i18n>CURRENT</small>
|
||||
<strong id="cockpit-vision-current-label" aria-live="polite">NORMAL</strong>
|
||||
</button>
|
||||
<button id="cockpit-vision-next" class="cockpit-vision-nav" type="button" aria-label="Next cockpit vision style" title="Next vision style" data-i18n-aria-label="cockpit.vision.nextAriaLabel" data-i18n-title="cockpit.vision.nextTitle">
|
||||
<small data-i18n="cockpit.vision.nextLabel">NEXT</small>
|
||||
<button id="cockpit-vision-next" class="cockpit-vision-nav" type="button" aria-label="Next cockpit vision style" title="Next vision style" data-i18n-aria-label data-i18n-title>
|
||||
<small data-i18n>NEXT</small>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cockpit-reticle material-symbols-outlined" aria-hidden="true">my_location</div>
|
||||
<div class="cockpit-compass" aria-label="Current aircraft heading" data-i18n-aria-label="cockpit.hud.compassAriaLabel">
|
||||
<div class="cockpit-compass" aria-label="Current aircraft heading" data-i18n-aria-label>
|
||||
<div class="cockpit-instrument-row">
|
||||
<div class="cockpit-readout cockpit-speed">
|
||||
<span class="cockpit-readout-label" data-i18n="cockpit.readout.groundSpeed">GROUND SPEED</span>
|
||||
<span class="cockpit-readout-label" data-i18n>GROUND SPEED</span>
|
||||
<strong id="cockpit-speed-value">---</strong>
|
||||
<span class="cockpit-readout-unit">KTS</span>
|
||||
</div>
|
||||
<div class="cockpit-heading-value"><span id="cockpit-heading-value">---</span><small>°</small></div>
|
||||
<div class="cockpit-readout cockpit-altitude">
|
||||
<span class="cockpit-readout-label" data-i18n="cockpit.readout.altitude">ALTITUDE</span>
|
||||
<span class="cockpit-readout-label" data-i18n>ALTITUDE</span>
|
||||
<strong id="cockpit-altitude-value">-----</strong>
|
||||
<span class="cockpit-readout-unit">FT</span>
|
||||
</div>
|
||||
@@ -153,26 +153,26 @@
|
||||
<span id="cockpit-clock">--:--:--Z</span>
|
||||
<span id="cockpit-position">--° · --°</span>
|
||||
</div>
|
||||
<aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" hidden data-i18n-aria-label="cockpit.context.panelAriaLabel">
|
||||
<aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" hidden data-i18n-aria-label>
|
||||
<header class="cockpit-context-header">
|
||||
<div>
|
||||
<span class="cockpit-context-kicker">CONTACT</span>
|
||||
<strong id="cockpit-context-subject" data-i18n="cockpit.context.subjectWindow">CONTACTS · 250 KM</strong>
|
||||
<strong id="cockpit-context-subject" data-i18n>CONTACTS · 250 KM</strong>
|
||||
</div>
|
||||
<nav class="cockpit-context-actions" aria-label="Contact navigation" data-i18n-aria-label="cockpit.context.navAriaLabel">
|
||||
<button id="cockpit-context-previous" type="button" aria-label="Previous — prior visited contact in the 250 km window" title="Previous — prior visited contact in the 250 km window" data-i18n-aria-label="cockpit.context.previousAriaLabel" data-i18n-title="cockpit.context.previousTitle">
|
||||
<nav class="cockpit-context-actions" aria-label="Contact navigation" data-i18n-aria-label>
|
||||
<button id="cockpit-context-previous" type="button" aria-label="Previous — prior visited contact in the 250 km window" title="Previous — prior visited contact in the 250 km window" data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">skip_previous</span>
|
||||
</button>
|
||||
<button id="cockpit-context-next" type="button" aria-label="Next — nearest unvisited contact in the 250 km window" title="Next — nearest unvisited contact in the 250 km window" data-i18n-aria-label="cockpit.context.nextAriaLabel" data-i18n-title="cockpit.context.nextTitle">
|
||||
<button id="cockpit-context-next" type="button" aria-label="Next — nearest unvisited contact in the 250 km window" title="Next — nearest unvisited contact in the 250 km window" data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">skip_next</span>
|
||||
</button>
|
||||
<button id="cockpit-context-toggle" type="button" aria-label="Collapse Contact panel" aria-expanded="true" title="Collapse contact panel" data-i18n-aria-label="cockpit.context.toggleAriaLabel" data-i18n-title="cockpit.context.toggleTitle">
|
||||
<button id="cockpit-context-toggle" type="button" aria-label="Collapse Contact panel" aria-expanded="true" title="Collapse contact panel" data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
|
||||
</button>
|
||||
</nav>
|
||||
<span class="cockpit-context-qualifier" data-i18n="cockpit.context.qualifier">CONTEXT ONLY</span>
|
||||
<span class="cockpit-context-qualifier" data-i18n>CONTEXT ONLY</span>
|
||||
</header>
|
||||
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts" data-i18n-aria-label="cockpit.context.cohortsAriaLabel">
|
||||
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts" data-i18n-aria-label>
|
||||
<div role="listitem" data-context-cohort="flights"><span>FLT</span><strong>—</strong><small>OpenSky</small></div>
|
||||
<div role="listitem" data-context-cohort="military"><span>MIL</span><strong>—</strong><small>adsb.lol</small></div>
|
||||
<div role="listitem" data-context-cohort="ais-live-vessels"><span>AIS</span><strong>—</strong><small>AISStream</small></div>
|
||||
@@ -181,22 +181,21 @@
|
||||
<div class="cockpit-context-nearest">
|
||||
<span id="cockpit-context-direction" class="material-symbols-outlined" aria-hidden="true">navigation</span>
|
||||
<div>
|
||||
<small data-i18n="cockpit.context.nearestLabel">NEAREST OBSERVED / MAPPED</small>
|
||||
<strong id="cockpit-context-nearest-label" data-i18n="cockpit.context.nearestEmpty">NO AVAILABLE EXAMPLE</strong>
|
||||
<small data-i18n>NEAREST OBSERVED / MAPPED</small>
|
||||
<strong id="cockpit-context-nearest-label" data-i18n>NO AVAILABLE EXAMPLE</strong>
|
||||
</div>
|
||||
<span id="cockpit-context-bearing">—</span>
|
||||
<span id="cockpit-context-distance">—</span>
|
||||
</div>
|
||||
<footer class="cockpit-context-footer">
|
||||
<span id="cockpit-context-uncertainty" data-i18n="cockpit.context.uncertaintyNote">AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR</span>
|
||||
<span id="cockpit-context-uncertainty" data-i18n>AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR</span>
|
||||
<button
|
||||
id="cockpit-weather-toggle"
|
||||
class="cockpit-context-weather"
|
||||
type="button"
|
||||
aria-label="Enable cockpit weather effects"
|
||||
aria-pressed="false"
|
||||
title="Enable cockpit weather effects"
|
||||
data-i18n-aria-label="cockpit.context.weatherEnableAriaLabel" data-i18n-title="cockpit.context.weatherEnableTitle"
|
||||
title="Enable cockpit weather effects" data-i18n-aria-label data-i18n-title
|
||||
>
|
||||
<span>WX</span>
|
||||
<strong id="cockpit-weather-state">OFF</strong>
|
||||
@@ -204,35 +203,35 @@
|
||||
<time id="cockpit-context-updated">--:--:--Z</time>
|
||||
</footer>
|
||||
</aside>
|
||||
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden data-i18n-aria-label="cockpit.brief.carouselAriaLabel">
|
||||
<section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" hidden data-i18n-aria-label="cockpit.route.cardAriaLabel">
|
||||
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden data-i18n-aria-label>
|
||||
<section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" hidden data-i18n-aria-label>
|
||||
<div class="cockpit-route-heading">
|
||||
<span class="cockpit-route-kicker" data-i18n="cockpit.route.kicker">ESTIMATED FLIGHT PLAN</span>
|
||||
<small id="cockpit-route-status" data-i18n="cockpit.route.statusUnavailable">ROUTE DATA UNAVAILABLE</small>
|
||||
<span class="cockpit-route-kicker" data-i18n>ESTIMATED FLIGHT PLAN</span>
|
||||
<small id="cockpit-route-status" data-i18n>ROUTE DATA UNAVAILABLE</small>
|
||||
</div>
|
||||
<div class="cockpit-route-endpoints">
|
||||
<div class="cockpit-route-airport">
|
||||
<span data-i18n="cockpit.route.fromLabel">FROM</span>
|
||||
<strong id="cockpit-route-from" data-i18n="cockpit.route.unknownEndpoint">UNKNOWN</strong>
|
||||
<span data-i18n>FROM</span>
|
||||
<strong id="cockpit-route-from" data-i18n>UNKNOWN</strong>
|
||||
</div>
|
||||
<span class="cockpit-route-arrow material-symbols-outlined" aria-hidden="true">east</span>
|
||||
<div class="cockpit-route-airport">
|
||||
<span data-i18n="cockpit.route.toLabel">TO</span>
|
||||
<strong id="cockpit-route-to" data-i18n="cockpit.route.unknownEndpoint">UNKNOWN</strong>
|
||||
<span data-i18n>TO</span>
|
||||
<strong id="cockpit-route-to" data-i18n>UNKNOWN</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<header class="cockpit-signal-header">
|
||||
<div class="cockpit-brief-heading">
|
||||
<span id="cockpit-brief-kicker" class="cockpit-signal-kicker"><i aria-hidden="true"></i> LIVE SIGNALS</span>
|
||||
<strong id="cockpit-brief-subtitle" data-i18n="cockpit.brief.subtitle">OBSERVED / MAPPED PINGS</strong>
|
||||
<strong id="cockpit-brief-subtitle" data-i18n>OBSERVED / MAPPED PINGS</strong>
|
||||
</div>
|
||||
<nav class="cockpit-brief-actions" aria-label="Cockpit briefing controls" data-i18n-aria-label="cockpit.brief.actionsAriaLabel">
|
||||
<button id="cockpit-brief-previous" type="button" aria-label="Previous briefing page" title="Previous briefing page" data-i18n-aria-label="cockpit.brief.previousAriaLabel" data-i18n-title="cockpit.brief.previousTitle">
|
||||
<nav class="cockpit-brief-actions" aria-label="Cockpit briefing controls" data-i18n-aria-label>
|
||||
<button id="cockpit-brief-previous" type="button" aria-label="Previous briefing page" title="Previous briefing page" data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
|
||||
</button>
|
||||
<span id="cockpit-brief-position" aria-live="polite">1 / 3</span>
|
||||
<button id="cockpit-brief-next" type="button" aria-label="Next briefing page" title="Next briefing page" data-i18n-aria-label="cockpit.brief.nextAriaLabel" data-i18n-title="cockpit.brief.nextTitle">
|
||||
<button id="cockpit-brief-next" type="button" aria-label="Next briefing page" title="Next briefing page" data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -240,53 +239,52 @@
|
||||
type="button"
|
||||
aria-label="CYCLE OFF"
|
||||
aria-pressed="false"
|
||||
title="Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way."
|
||||
data-i18n-title="cockpit.brief.autoTitle"
|
||||
title="Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way." data-i18n-title
|
||||
>CYCLE OFF</button>
|
||||
<button id="cockpit-signal-toggle" type="button" aria-label="Collapse cockpit briefing panel" aria-expanded="true" title="Collapse briefing panel" data-i18n-aria-label="cockpit.brief.collapseAriaLabel" data-i18n-title="cockpit.brief.collapseTitle">
|
||||
<button id="cockpit-signal-toggle" type="button" aria-label="Collapse cockpit briefing panel" aria-expanded="true" title="Collapse briefing panel" data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">right_panel_close</span>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="cockpit-brief-stage">
|
||||
<section data-cockpit-brief-page="signals" aria-label="Live signals" data-i18n-aria-label="cockpit.brief.signalsAriaLabel">
|
||||
<section data-cockpit-brief-page="signals" aria-label="Live signals" data-i18n-aria-label>
|
||||
<ol id="cockpit-signal-list" class="cockpit-signal-list" aria-live="polite"></ol>
|
||||
</section>
|
||||
<section data-cockpit-brief-page="news" aria-label="Latest regional news" hidden data-i18n-aria-label="cockpit.brief.newsAriaLabel">
|
||||
<div id="cockpit-news-status" class="cockpit-brief-status" data-i18n="cockpit.brief.newsAcquiring">ACQUIRING REGIONAL NEWS</div>
|
||||
<section data-cockpit-brief-page="news" aria-label="Latest regional news" hidden data-i18n-aria-label>
|
||||
<div id="cockpit-news-status" class="cockpit-brief-status" data-i18n>ACQUIRING REGIONAL NEWS</div>
|
||||
<ol id="cockpit-news-list" class="cockpit-news-list" aria-live="polite"></ol>
|
||||
</section>
|
||||
<section data-cockpit-brief-page="local" aria-label="Location-based information" hidden data-i18n-aria-label="cockpit.brief.localAriaLabel">
|
||||
<section data-cockpit-brief-page="local" aria-label="Location-based information" hidden data-i18n-aria-label>
|
||||
<div class="cockpit-local-place">
|
||||
<strong id="cockpit-local-place" data-i18n="cockpit.brief.localResolving">RESOLVING REGION</strong>
|
||||
<strong id="cockpit-local-place" data-i18n>RESOLVING REGION</strong>
|
||||
<small id="cockpit-local-coordinates">--</small>
|
||||
</div>
|
||||
<div class="cockpit-local-grid">
|
||||
<div data-i18n="cockpit.brief.labelTemp"><span>TEMP</span><strong id="cockpit-local-temperature">—</strong><small>OPEN-METEO</small></div>
|
||||
<div data-i18n="cockpit.brief.labelWind"><span>WIND</span><strong id="cockpit-local-wind">—</strong><small id="cockpit-local-wind-direction">—</small></div>
|
||||
<div data-i18n="cockpit.brief.labelSky"><span>SKY</span><strong id="cockpit-local-condition">UNKNOWN</strong><small id="cockpit-local-cloud">—</small></div>
|
||||
<div data-i18n="cockpit.brief.labelPrecip"><span>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small>MM</small></div>
|
||||
<div><span data-i18n>TEMP</span><strong id="cockpit-local-temperature">—</strong><small>OPEN-METEO</small></div>
|
||||
<div><span data-i18n>WIND</span><strong id="cockpit-local-wind">—</strong><small id="cockpit-local-wind-direction">—</small></div>
|
||||
<div><span data-i18n>SKY</span><strong id="cockpit-local-condition">UNKNOWN</strong><small id="cockpit-local-cloud">—</small></div>
|
||||
<div><span data-i18n>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small>MM</small></div>
|
||||
</div>
|
||||
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener">Weather data by Open-Meteo.com</a>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="cockpit-signal-footer">
|
||||
<span id="cockpit-brief-source" data-i18n="cockpit.brief.sourceNote">SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS</span>
|
||||
<span id="cockpit-brief-source" data-i18n>SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS</span>
|
||||
<nav class="cockpit-brief-tabs" aria-label="Cockpit briefing pages">
|
||||
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" aria-current="true" data-i18n-aria-label="cockpit.brief.tabSignalsAriaLabel">SIG</button>
|
||||
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News" data-i18n-aria-label="cockpit.brief.tabNewsAriaLabel">NEWS</button>
|
||||
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info" data-i18n-aria-label="cockpit.brief.tabLocalAriaLabel">LOCAL</button>
|
||||
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" aria-current="true" data-i18n-aria-label>SIG</button>
|
||||
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News" data-i18n-aria-label>NEWS</button>
|
||||
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info" data-i18n-aria-label>LOCAL</button>
|
||||
</nav>
|
||||
</footer>
|
||||
</aside>
|
||||
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" aria-label="Cockpit display and Radio controls" data-i18n-aria-label="cockpit.utility.controlsAriaLabel">
|
||||
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" aria-label="Cockpit display and Radio controls" data-i18n-aria-label>
|
||||
<div class="cockpit-utility-control">
|
||||
<div class="cockpit-utility-launcher" data-cockpit-launcher="display">
|
||||
<span data-i18n="cockpit.utility.displayLabel">DISPLAY</span>
|
||||
<span data-i18n>DISPLAY</span>
|
||||
<span class="cockpit-utility-divider" aria-hidden="true"></span>
|
||||
<button id="cockpit-display-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-display-panel" aria-label="Expand Cockpit display options" title="Expand Cockpit display options" data-i18n-aria-label="cockpit.utility.displayToggleAriaLabel" data-i18n-title="cockpit.utility.displayToggleTitle">◀</button>
|
||||
<button id="cockpit-display-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-display-panel" aria-label="Expand Cockpit display options" title="Expand Cockpit display options" data-i18n-aria-label data-i18n-title>◀</button>
|
||||
</div>
|
||||
<div id="cockpit-display-panel" class="cockpit-utility-popover" role="group" aria-label="Cockpit display options" hidden data-i18n-aria-label="cockpit.utility.displayPanelAriaLabel">
|
||||
<div id="cockpit-display-panel" class="cockpit-utility-popover" role="group" aria-label="Cockpit display options" hidden data-i18n-aria-label>
|
||||
<div class="cockpit-display-slot" data-cockpit-display-slot="hud"></div>
|
||||
<div class="cockpit-display-slot" data-cockpit-display-slot="detection"></div>
|
||||
<div class="cockpit-display-slot" data-cockpit-display-slot="parameters"></div>
|
||||
@@ -295,21 +293,21 @@
|
||||
</div>
|
||||
<div class="cockpit-utility-control">
|
||||
<div class="cockpit-utility-launcher" data-cockpit-launcher="radio">
|
||||
<span data-i18n="cockpit.utility.radioLabel">RADIO</span>
|
||||
<span data-i18n>RADIO</span>
|
||||
<span class="cockpit-utility-divider" aria-hidden="true"></span>
|
||||
<button id="cockpit-radio-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-radio-panel" aria-label="Expand Cockpit Radio controls" title="Expand Cockpit Radio controls" data-i18n-aria-label="cockpit.utility.radioToggleAriaLabel" data-i18n-title="cockpit.utility.radioToggleTitle">◀</button>
|
||||
<button id="cockpit-radio-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-radio-panel" aria-label="Expand Cockpit Radio controls" title="Expand Cockpit Radio controls" data-i18n-aria-label data-i18n-title>◀</button>
|
||||
</div>
|
||||
<div id="cockpit-radio-panel" class="cockpit-utility-popover cockpit-radio-compact" role="group" aria-label="Cockpit compact Radio controls" hidden data-i18n-aria-label="cockpit.utility.radioPanelAriaLabel">
|
||||
<strong id="cockpit-radio-station" class="cockpit-radio-station" data-i18n="cockpit.radio.station.ready">READY</strong>
|
||||
<button id="cockpit-radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n="cockpit.radio.enable">ENABLE</button>
|
||||
<div id="cockpit-radio-panel" class="cockpit-utility-popover cockpit-radio-compact" role="group" aria-label="Cockpit compact Radio controls" hidden data-i18n-aria-label>
|
||||
<strong id="cockpit-radio-station" class="cockpit-radio-station" data-i18n>READY</strong>
|
||||
<button id="cockpit-radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
|
||||
<div class="cockpit-radio-transport">
|
||||
<button id="cockpit-radio-prev-btn" type="button" aria-label="Previous filtered radio station" disabled data-i18n-aria-label="cockpit.radio.prevAriaLabel">◀◀</button>
|
||||
<button id="cockpit-radio-play-btn" type="button" aria-label="Play selected radio station" disabled data-i18n-aria-label="cockpit.radio.playAriaLabel">▶</button>
|
||||
<button id="cockpit-radio-next-btn" type="button" aria-label="Next filtered radio station" disabled data-i18n-aria-label="cockpit.radio.nextAriaLabel">▶▶</button>
|
||||
<button id="cockpit-radio-prev-btn" type="button" aria-label="Previous filtered radio station" disabled data-i18n-aria-label>◀◀</button>
|
||||
<button id="cockpit-radio-play-btn" type="button" aria-label="Play selected radio station" disabled data-i18n-aria-label>▶</button>
|
||||
<button id="cockpit-radio-next-btn" type="button" aria-label="Next filtered radio station" disabled data-i18n-aria-label>▶▶</button>
|
||||
</div>
|
||||
<label class="cockpit-radio-volume" for="cockpit-radio-volume">
|
||||
<span data-i18n="cockpit.radio.volumeLabel">VOLUME</span>
|
||||
<input id="cockpit-radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Cockpit Radio volume" disabled data-i18n-aria-label="cockpit.radio.volumeAriaLabel" />
|
||||
<span data-i18n>VOLUME</span>
|
||||
<input id="cockpit-radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Cockpit Radio volume" disabled data-i18n-aria-label />
|
||||
<output id="cockpit-radio-volume-value" class="gev-slider-value" for="cockpit-radio-volume">80%</output>
|
||||
</label>
|
||||
</div>
|
||||
@@ -318,13 +316,13 @@
|
||||
<div class="cockpit-help">ESC EXIT <span>·</span> C TOGGLE</div>
|
||||
</section>
|
||||
|
||||
<nav id="view-switcher" aria-label="View switcher" data-i18n-aria-label="cockpit.exit.navAriaLabel">
|
||||
<button id="cockpit-reset-globe" type="button" aria-label="Reset cockpit to full globe view" title="Exit cockpit and return to full globe view" hidden data-i18n-aria-label="cockpit.exit.resetAriaLabel" data-i18n-title="cockpit.exit.resetTitle">
|
||||
<nav id="view-switcher" aria-label="View switcher" data-i18n-aria-label>
|
||||
<button id="cockpit-reset-globe" type="button" aria-label="Reset cockpit to full globe view" title="Exit cockpit and return to full globe view" hidden data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">public</span>
|
||||
<span data-i18n="cockpit.exit.resetLabel">RESET</span>
|
||||
<span data-i18n>RESET</span>
|
||||
</button>
|
||||
<button id="map-view-switch" type="button" aria-label="Exit cockpit view" aria-keyshortcuts="Escape C" title="Exit cockpit view" hidden data-i18n-aria-label="cockpit.exit.ariaLabel" data-i18n-title="cockpit.exit.title">
|
||||
<button id="map-view-switch" type="button" aria-label="Exit cockpit view" aria-keyshortcuts="Escape C" title="Exit cockpit view" hidden data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">close_fullscreen</span>
|
||||
<span data-i18n="cockpit.exit.label">EXIT COCKPIT</span>
|
||||
<span data-i18n>EXIT COCKPIT</span>
|
||||
</button>
|
||||
</nav>
|
||||
@@ -1,58 +1,53 @@
|
||||
<!-- Unified bottom command dock: locations, voice (injected at runtime), visual presets -->
|
||||
<div id="command-dock" aria-label="Navigation, voice, and visual preset controls" data-i18n-aria-label="shell.dock.ariaLabel">
|
||||
<div id="command-dock" aria-label="Navigation, voice, and visual preset controls" data-i18n-aria-label>
|
||||
<!-- Control panel -->
|
||||
<div id="control-panel" class="panel-collapsible collapsed">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="panel-inner">
|
||||
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
|
||||
data-dock-toggle-target="control-panel" aria-expanded="false"
|
||||
aria-controls="control-panel-popover" aria-label="Expand Visual Presets" data-i18n-aria-label="cockpit.presets.toggleAriaLabel">
|
||||
aria-controls="control-panel-popover" aria-label="Expand Visual Presets" data-i18n-aria-label>
|
||||
<span class="panel-title"><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
|
||||
<span class="panel-divider"></span>
|
||||
</button>
|
||||
<!-- Language selector: only the group container is static (its
|
||||
data-i18n-aria-label is applied from the catalog at boot). The
|
||||
buttons are rendered at runtime by ui.js _initLocaleSelector(): one
|
||||
per locale in the configured GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE
|
||||
pair plus the always-shipped English fallback, deduped, labels EN/ES/FR
|
||||
with aria-labels from shell.locale.<code>.ariaLabel. -->
|
||||
<div class="dock-locale-switch" role="group" data-i18n-aria-label="shell.locale.groupAriaLabel"></div>
|
||||
<!-- Language selector: buttons are rendered by applicationShell _initLocaleSelector(), one per shipped locale. -->
|
||||
<div class="dock-locale-switch" role="group" data-i18n-aria-label aria-label="Language"></div>
|
||||
<div id="control-panel-popover" class="dock-popover-content">
|
||||
<button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open" data-i18n-aria-label="cockpit.presets.pinAriaLabel" data-i18n-title="cockpit.presets.pinTitle"><img src="/pin.svg" alt="" /></button>
|
||||
<button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open" data-i18n-aria-label data-i18n-title><img src="/pin.svg" alt="" /></button>
|
||||
<div id="style-buttons" class="button-grid">
|
||||
<button class="style-btn active" data-style="normal" title="Show the globe without a visual filter." data-i18n-title="cockpit.presets.styleNormalTitle">
|
||||
<button class="style-btn active" data-style="normal" title="Show the globe without a visual filter." data-i18n-title>
|
||||
<span class="btn-icon">◯</span>
|
||||
<span class="btn-label" data-i18n="cockpit.presets.styleNormalLabel">Normal</span>
|
||||
<span class="btn-label" data-i18n>Normal</span>
|
||||
<span class="btn-key">1</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="retro" title="Emulate a green phosphor CRT with scanlines and screen curvature." data-i18n-title="cockpit.presets.styleCrtTitle">
|
||||
<button class="style-btn" data-style="retro" title="Emulate a green phosphor CRT with scanlines and screen curvature." data-i18n-title>
|
||||
<span class="btn-icon">▦</span>
|
||||
<span class="btn-label" data-i18n="cockpit.presets.styleCrtLabel">CRT</span>
|
||||
<span class="btn-label" data-i18n>CRT</span>
|
||||
<span class="btn-key">2</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="surveillance" title="Simulate night-vision goggles with green intensification and a tube vignette." data-i18n-title="cockpit.presets.styleNvgTitle">
|
||||
<button class="style-btn" data-style="surveillance" title="Simulate night-vision goggles with green intensification and a tube vignette." data-i18n-title>
|
||||
<span class="btn-icon">🌙</span>
|
||||
<span class="btn-label" data-i18n="cockpit.presets.styleNvgLabel">NVG</span>
|
||||
<span class="btn-label" data-i18n>NVG</span>
|
||||
<span class="btn-key">3</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="thermal" title="Simulate FLIR-style thermal contrast. Turn up Ironbow for color." data-i18n-title="cockpit.presets.styleFlirTitle">
|
||||
<button class="style-btn" data-style="thermal" title="Simulate FLIR-style thermal contrast. Turn up Ironbow for color." data-i18n-title>
|
||||
<span class="btn-icon">🌡️</span>
|
||||
<span class="btn-label" data-i18n="cockpit.presets.styleFlirLabel">FLIR</span>
|
||||
<span class="btn-label" data-i18n>FLIR</span>
|
||||
<span class="btn-key">4</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="anime" title="Apply bright cel-shaded color and illustrated outlines." data-i18n-title="cockpit.presets.styleAnimeTitle">
|
||||
<button class="style-btn" data-style="anime" title="Apply bright cel-shaded color and illustrated outlines." data-i18n-title>
|
||||
<span class="btn-icon">✦</span>
|
||||
<span class="btn-label" data-i18n="cockpit.presets.styleAnimeLabel">Anime</span>
|
||||
<span class="btn-label" data-i18n>Anime</span>
|
||||
<span class="btn-key">5</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="noir" title="Apply high-contrast monochrome film-noir grading." data-i18n-title="cockpit.presets.styleNoirTitle">
|
||||
<button class="style-btn" data-style="noir" title="Apply high-contrast monochrome film-noir grading." data-i18n-title>
|
||||
<span class="btn-icon">◐</span>
|
||||
<span class="btn-label" data-i18n="cockpit.presets.styleNoirLabel">Noir</span>
|
||||
<span class="btn-label" data-i18n>Noir</span>
|
||||
<span class="btn-key">6</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="snow" title="Add a cold, snowy whiteout treatment to the scene." data-i18n-title="cockpit.presets.styleSnowTitle">
|
||||
<button class="style-btn" data-style="snow" title="Add a cold, snowy whiteout treatment to the scene." data-i18n-title>
|
||||
<span class="btn-icon">❄</span>
|
||||
<span class="btn-label" data-i18n="cockpit.presets.styleSnowLabel">Snow</span>
|
||||
<span class="btn-label" data-i18n>Snow</span>
|
||||
<span class="btn-key">7</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -65,7 +60,7 @@
|
||||
</section>
|
||||
</div>
|
||||
<div id="style-mini-status" class="panel-mini-status" aria-live="polite">
|
||||
<span class="panel-mini-label" data-i18n="cockpit.presets.miniStyleLabel">Style</span>
|
||||
<span class="panel-mini-label" data-i18n>Style</span>
|
||||
<span id="style-mini-value" class="panel-mini-value">NORMAL</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,26 +71,26 @@
|
||||
<div class="panel-glow"></div>
|
||||
<div class="location-inner">
|
||||
<div class="location-toolbar">
|
||||
<button id="location-bar-toggle" class="dock-tray-toggle" type="button" data-dock-toggle-target="location-bar" aria-controls="location-bar-popover" aria-expanded="false" aria-label="Expand LOCATION" title="Expand LOCATION" data-i18n-aria-label="cockpit.location.toggleAria" data-i18n-title="cockpit.location.toggleAria">
|
||||
<button id="location-bar-toggle" class="dock-tray-toggle" type="button" data-dock-toggle-target="location-bar" aria-controls="location-bar-popover" aria-expanded="false" aria-label="Expand LOCATION" title="Expand LOCATION" data-i18n-aria-label data-i18n-title>
|
||||
<span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
|
||||
</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel" data-i18n-title="cockpit.location.collapseTitle">+</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel" data-i18n-title>+</button>
|
||||
</div>
|
||||
<div id="location-mini-status" class="location-mini-status" aria-live="polite">
|
||||
<div id="location-mini-city" class="location-mini-city" data-i18n="cockpit.location.miniCityInitial">📍 Location: --</div>
|
||||
<div id="location-mini-poi" class="location-mini-poi" data-i18n="cockpit.location.miniPoiInitial">Landmark: --</div>
|
||||
<div id="location-mini-city" class="location-mini-city" data-i18n>📍 Location: --</div>
|
||||
<div id="location-mini-poi" class="location-mini-poi" data-i18n>Landmark: --</div>
|
||||
</div>
|
||||
<div id="location-bar-popover" class="dock-popover-content">
|
||||
<button class="dock-pin-btn" data-pin-target="location-bar" type="button" aria-label="Pin location tray" aria-pressed="false" title="Keep location tray open" data-i18n-aria-label="cockpit.location.pinAriaLabel" data-i18n-title="cockpit.location.pinTitle"><img src="/pin.svg" alt="" /></button>
|
||||
<button class="dock-pin-btn" data-pin-target="location-bar" type="button" aria-label="Pin location tray" aria-pressed="false" title="Keep location tray open" data-i18n-aria-label data-i18n-title><img src="/pin.svg" alt="" /></button>
|
||||
<div id="poi-row" class="poi-row-container"></div>
|
||||
<div class="location-bar-divider" id="location-bar-divider"></div>
|
||||
<div class="location-city-row">
|
||||
<div id="location-pills" class="location-pills"></div>
|
||||
<div class="location-search-wrap">
|
||||
<button id="search-toggle" class="search-toggle-btn" title="Search any location" data-i18n-title="cockpit.location.searchToggleTitle">
|
||||
<button id="search-toggle" class="search-toggle-btn" title="Search any location" data-i18n-title>
|
||||
<span>🔎</span>
|
||||
</button>
|
||||
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" data-i18n-aria-label="cockpit.location.searchAria" data-i18n-placeholder="cockpit.location.searchPlaceholder" />
|
||||
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" data-i18n-aria-label data-i18n-placeholder />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,112 +28,112 @@
|
||||
<div class="panel-glow"></div>
|
||||
<div class="global-context-panel-inner" data-rail-scroller>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n="cockpit.context.panelTitle">CONTEXT</span>
|
||||
<span class="panel-title" data-i18n>CONTEXT</span>
|
||||
<span class="panel-divider"></span>
|
||||
<div id="context-radio-dock" class="context-radio-dock">
|
||||
<button id="context-radio-toggle-btn" class="context-radio-toggle" type="button" aria-expanded="false" aria-controls="context-radio-mini" aria-label="Open compact Radio controls" title="Open compact Radio controls" data-i18n-aria-label="cockpit.context.radioToggleAriaLabel" data-i18n-title="cockpit.context.radioToggleTitle">
|
||||
<button id="context-radio-toggle-btn" class="context-radio-toggle" type="button" aria-expanded="false" aria-controls="context-radio-mini" aria-label="Open compact Radio controls" title="Open compact Radio controls" data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">radio</span>
|
||||
</button>
|
||||
<div id="context-radio-mini" class="context-radio-mini" role="group" aria-label="Compact Radio controls" hidden data-i18n-aria-label="cockpit.context.radioMiniGroupAriaLabel">
|
||||
<div id="context-radio-mini" class="context-radio-mini" role="group" aria-label="Compact Radio controls" hidden data-i18n-aria-label>
|
||||
<div class="context-radio-mini-heading">
|
||||
<span data-i18n="cockpit.context.radioMiniTitle">RADIO</span>
|
||||
<strong id="context-radio-mini-station" data-i18n="cockpit.context.radioMiniReady">RADIO READY</strong>
|
||||
<span data-i18n>RADIO</span>
|
||||
<strong id="context-radio-mini-station" data-i18n>RADIO READY</strong>
|
||||
<span class="context-radio-mini-actions">
|
||||
<button id="context-radio-details-btn" type="button" aria-label="Open detailed Radio controls" aria-expanded="false" aria-controls="radio-panel" title="Open detailed Radio controls" data-i18n-aria-label="cockpit.context.radioDetailsAriaLabel" data-i18n-title="cockpit.context.radioDetailsTitle">
|
||||
<button id="context-radio-details-btn" type="button" aria-label="Open detailed Radio controls" aria-expanded="false" aria-controls="radio-panel" title="Open detailed Radio controls" data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">open_in_full</span>
|
||||
</button>
|
||||
<button id="context-radio-mini-close-btn" type="button" aria-label="Close compact Radio controls" title="Close compact Radio controls" data-i18n-aria-label="cockpit.context.radioCloseAriaLabel" data-i18n-title="cockpit.context.radioCloseTitle">
|
||||
<button id="context-radio-mini-close-btn" type="button" aria-label="Close compact Radio controls" title="Close compact Radio controls" data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">close</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<button id="context-radio-mini-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n="cockpit.context.radioMiniEnable">ENABLE</button>
|
||||
<button id="context-radio-mini-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
|
||||
<div class="context-radio-mini-transport">
|
||||
<button id="context-radio-mini-prev-btn" type="button" aria-label="Previous filtered radio station" title="Previous station" disabled data-i18n-aria-label="cockpit.context.radioMiniPrevAriaLabel" data-i18n-title="cockpit.context.radioMiniPrevTitle">◀◀</button>
|
||||
<button id="context-radio-mini-play-btn" type="button" aria-label="Play selected radio station" title="Play" disabled data-i18n-aria-label="cockpit.context.radioMiniPlayAriaLabel" data-i18n-title="cockpit.context.radioMiniPlayTitle">▶</button>
|
||||
<button id="context-radio-mini-next-btn" type="button" aria-label="Next filtered radio station" title="Next station" disabled data-i18n-aria-label="cockpit.context.radioMiniNextAriaLabel" data-i18n-title="cockpit.context.radioMiniNextTitle">▶▶</button>
|
||||
<button id="context-radio-mini-prev-btn" type="button" aria-label="Previous filtered radio station" title="Previous station" disabled data-i18n-aria-label data-i18n-title>◀◀</button>
|
||||
<button id="context-radio-mini-play-btn" type="button" aria-label="Play selected radio station" title="Play" disabled data-i18n-aria-label data-i18n-title>▶</button>
|
||||
<button id="context-radio-mini-next-btn" type="button" aria-label="Next filtered radio station" title="Next station" disabled data-i18n-aria-label data-i18n-title>▶▶</button>
|
||||
</div>
|
||||
<label class="context-radio-mini-volume" for="context-radio-mini-volume">
|
||||
<span data-i18n="cockpit.context.radioMiniVolumeLabel">VOLUME</span>
|
||||
<input id="context-radio-mini-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Compact Radio volume" disabled data-i18n-aria-label="cockpit.context.radioMiniVolumeAriaLabel" />
|
||||
<span data-i18n>VOLUME</span>
|
||||
<input id="context-radio-mini-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Compact Radio volume" disabled data-i18n-aria-label />
|
||||
<output id="context-radio-mini-volume-value" class="gev-slider-value" for="context-radio-mini-volume">80%</output>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="panel-collapse-btn" data-collapse-target="global-context-panel" title="Expand panel" data-i18n-title="cockpit.context.collapseTitle">▶</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="global-context-panel" title="Expand panel" data-i18n-title>▶</button>
|
||||
</div>
|
||||
<div class="cyber-panel-body" data-rail-scroller>
|
||||
<div class="global-context-modes" role="tablist" aria-label="Context mode" data-i18n-aria-label="cockpit.context.modesAriaLabel">
|
||||
<button id="global-context-flights-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently." data-i18n-aria-label="cockpit.context.contactsTabAriaLabel" data-i18n-title="cockpit.context.contactsTabTitle">
|
||||
<div class="global-context-modes" role="tablist" aria-label="Context mode" data-i18n-aria-label>
|
||||
<button id="global-context-flights-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently." data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
|
||||
<span data-i18n="cockpit.context.contactsTabLabel">CONTACTS</span>
|
||||
<span data-i18n>CONTACTS</span>
|
||||
</button>
|
||||
<button id="global-context-missions-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-missions-view">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
|
||||
<span data-i18n="cockpit.context.missionsTabLabel">SPACE MISSIONS</span>
|
||||
<span data-i18n>SPACE MISSIONS</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="context-mode-standby" class="context-mode-standby">
|
||||
<strong data-i18n="cockpit.context.standbyTitle">SELECT CONTEXT</strong>
|
||||
<strong data-i18n>SELECT CONTEXT</strong>
|
||||
<span>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches & orbital assets</span>
|
||||
</div>
|
||||
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" hidden>
|
||||
<div class="global-context-actions" aria-label="Contact Context actions" data-i18n-aria-label="cockpit.context.actionsAriaLabel">
|
||||
<div class="global-context-actions" aria-label="Contact Context actions" data-i18n-aria-label>
|
||||
<button id="cockpit-entry" type="button" hidden>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">flight</span>
|
||||
<span data-i18n="cockpit.context.cockpitEntryLabel">COCKPIT</span>
|
||||
<span data-i18n>COCKPIT</span>
|
||||
</button>
|
||||
<button id="installations-search-btn" class="panel-layer-toggle" type="button" data-i18n="cockpit.context.searchNearbyLabel">SEARCH NEARBY SITES</button>
|
||||
<button id="tr3b-toggle" class="tr3b-toggle" type="button" aria-pressed="false" hidden aria-label="Reclassify tracked contact as TR-3B" title="Reclassify as TR-3B" data-i18n-aria-label="cockpit.context.tr3bAriaLabel" data-i18n-title="cockpit.context.tr3bTitle">🛸</button>
|
||||
<button id="installations-search-btn" class="panel-layer-toggle" type="button" data-i18n>SEARCH NEARBY SITES</button>
|
||||
<button id="tr3b-toggle" class="tr3b-toggle" type="button" aria-pressed="false" hidden aria-label="Reclassify tracked contact as TR-3B" title="Reclassify as TR-3B" data-i18n-aria-label data-i18n-title>🛸</button>
|
||||
</div>
|
||||
<aside id="military-awareness-panel">
|
||||
<div class="military-awareness-standby">
|
||||
<strong data-i18n="cockpit.context.awarenessOffTitle">CONTACTS CONTEXT OFF</strong>
|
||||
<span data-i18n="cockpit.context.awarenessOffHint">SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
|
||||
<strong data-i18n>CONTACTS CONTEXT OFF</strong>
|
||||
<span data-i18n>SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" hidden>
|
||||
<div id="space-mission-panel-host">
|
||||
<div id="space-mission-roster" class="space-mission-roster" aria-label="Available Space Missions" data-i18n-aria-label="cockpit.context.rosterAriaLabel">
|
||||
<div id="space-mission-roster" class="space-mission-roster" aria-label="Available Space Missions" data-i18n-aria-label>
|
||||
<div class="space-mission-roster-header">
|
||||
<div>
|
||||
<strong data-i18n="cockpit.context.rosterTitle">AVAILABLE MISSIONS</strong>
|
||||
<span data-i18n="cockpit.context.rosterHint">SELECT A MISSION TO INSPECT</span>
|
||||
<strong data-i18n>AVAILABLE MISSIONS</strong>
|
||||
<span data-i18n>SELECT A MISSION TO INSPECT</span>
|
||||
</div>
|
||||
<output data-mission-roster-count>—</output>
|
||||
</div>
|
||||
<div class="space-mission-roster-list" data-mission-roster-list>
|
||||
<div class="space-mission-roster-empty" data-i18n="cockpit.context.rosterLoading">LOADING 30-DAY MISSION INDEX</div>
|
||||
<div class="space-mission-roster-empty" data-i18n>LOADING 30-DAY MISSION INDEX</div>
|
||||
</div>
|
||||
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation data-i18n="layers.missions.roster.keyboardHint">TAB PREVIEWS · ENTER / SPACE SELECTS</p>
|
||||
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation data-i18n>TAB PREVIEWS · ENTER / SPACE SELECTS</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio and local SDR" data-i18n-aria-label="layers.radio.panelAriaLabel">
|
||||
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio and local SDR" data-i18n-aria-label>
|
||||
<div class="radio-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n="layers.radio.panelTitle">RADIO</span>
|
||||
<span class="panel-title" data-i18n>RADIO</span>
|
||||
<span id="radio-layer-state" class="radio-layer-state">OFF</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="radio-panel" title="Expand Radio" aria-label="Expand Radio section" data-i18n-title="layers.radio.expandTitle" data-i18n-aria-label="layers.radio.expandAriaLabel">+</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="radio-panel" title="Expand Radio" aria-label="Expand Radio section" data-i18n-title data-i18n-aria-label>+</button>
|
||||
</div>
|
||||
<div class="radio-directory-row">
|
||||
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n="layers.radio.enable">ENABLE</button>
|
||||
<label class="radio-filter-label" for="radio-filter" data-i18n="layers.radio.stationTagLabel">STATION TAG</label>
|
||||
<select id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label="layers.radio.filterAriaLabel">
|
||||
<option value="all" data-i18n="layers.radio.filterAll">All</option>
|
||||
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
|
||||
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
|
||||
<select id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label>
|
||||
<option value="all" data-i18n>All</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="radio-station-card" class="radio-station-card" aria-live="polite">
|
||||
<strong id="radio-station-name" data-i18n="layers.radio.noStation">NO STATION SELECTED</strong>
|
||||
<span id="radio-station-meta" data-i18n="layers.radio.stationHint">Enable Radio, then choose a globe marker or use next.</span>
|
||||
<strong id="radio-station-name" data-i18n>NO STATION SELECTED</strong>
|
||||
<span id="radio-station-meta" data-i18n>Enable Radio, then choose a globe marker or use next.</span>
|
||||
<span id="radio-station-tags"></span>
|
||||
</div>
|
||||
<div id="radio-tuner" class="radio-tuner" hidden>
|
||||
<div class="radio-tuner-header">
|
||||
<span id="radio-tuner-band-label">DIRECTORY BAND</span>
|
||||
<output id="radio-tuner-value" for="radio-tuner-slider" data-i18n="layers.radio.dragToTune">DRAG TO TUNE</output>
|
||||
<output id="radio-tuner-value" for="radio-tuner-slider" data-i18n>DRAG TO TUNE</output>
|
||||
</div>
|
||||
<div class="radio-tuner-dial">
|
||||
<div class="radio-tuner-scale" aria-hidden="true"></div>
|
||||
@@ -142,27 +142,27 @@
|
||||
aria-label="Tune available internet radio stations" aria-valuetext="No station available" disabled />
|
||||
</div>
|
||||
<div class="radio-tuner-readout">
|
||||
<span id="radio-tuner-station" data-i18n="layers.radio.tunerIdle">ALL · DRAG THE NEEDLE</span>
|
||||
<span data-i18n="layers.radio.snapsNote">SNAPS TO AVAILABLE STATIONS</span>
|
||||
<span id="radio-tuner-station" data-i18n>ALL · DRAG THE NEEDLE</span>
|
||||
<span data-i18n>SNAPS TO AVAILABLE STATIONS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="radio-transport" role="group" aria-label="Radio playback" data-i18n-aria-label="layers.radio.transportAriaLabel">
|
||||
<button id="radio-prev-btn" type="button" aria-label="Previous filtered station" disabled data-i18n-aria-label="layers.radio.prevAriaLabel" data-i18n="layers.radio.prev">PREV</button>
|
||||
<button id="radio-play-btn" type="button" aria-label="Play selected station" disabled data-i18n-aria-label="layers.radio.playAriaLabel" data-i18n="layers.radio.play">PLAY</button>
|
||||
<button id="radio-next-btn" type="button" aria-label="Next filtered station" disabled data-i18n-aria-label="layers.radio.nextAriaLabel" data-i18n="layers.radio.next">NEXT</button>
|
||||
<button id="radio-stop-btn" type="button" aria-label="Stop radio playback" disabled data-i18n-aria-label="layers.radio.stopAriaLabel" data-i18n="layers.radio.stop">STOP</button>
|
||||
<div class="radio-transport" role="group" aria-label="Radio playback" data-i18n-aria-label>
|
||||
<button id="radio-prev-btn" type="button" aria-label="Previous filtered station" disabled data-i18n-aria-label data-i18n>PREV</button>
|
||||
<button id="radio-play-btn" type="button" aria-label="Play selected station" disabled data-i18n-aria-label data-i18n>PLAY</button>
|
||||
<button id="radio-next-btn" type="button" aria-label="Next filtered station" disabled data-i18n-aria-label data-i18n>NEXT</button>
|
||||
<button id="radio-stop-btn" type="button" aria-label="Stop radio playback" disabled data-i18n-aria-label data-i18n>STOP</button>
|
||||
</div>
|
||||
<label class="radio-volume-row" for="radio-volume">
|
||||
<span data-i18n="layers.radio.volumeLabel">VOLUME</span>
|
||||
<input id="radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Radio volume" data-i18n-aria-label="layers.radio.volumeAriaLabel" />
|
||||
<span data-i18n>VOLUME</span>
|
||||
<input id="radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Radio volume" data-i18n-aria-label />
|
||||
<output id="radio-volume-value" class="gev-slider-value" for="radio-volume">80%</output>
|
||||
</label>
|
||||
<div id="radio-playback-state" class="radio-playback-state" role="status" aria-live="polite" data-i18n="layers.radio.playbackOff">Radio off</div>
|
||||
<div id="radio-playback-state" class="radio-playback-state" role="status" aria-live="polite" data-i18n>Radio off</div>
|
||||
<div class="radio-links">
|
||||
<a id="radio-station-homepage" href="#" target="_blank" rel="noopener noreferrer" hidden data-i18n="layers.radio.stationSite">STATION SITE</a>
|
||||
<a id="radio-station-homepage" href="#" target="_blank" rel="noopener noreferrer" hidden data-i18n>STATION SITE</a>
|
||||
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">DIRECTORY: RADIO BROWSER</a>
|
||||
</div>
|
||||
<p class="radio-privacy" data-i18n="layers.radio.privacyNote">Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
|
||||
<p class="radio-privacy" data-i18n>Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
|
||||
<p class="radio-privacy">Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
|
||||
<div id="sdr-radio-card" class="sdr-radio-card" role="group" aria-label="Local RTL-SDR receiver">
|
||||
<div class="sdr-radio-heading">
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
<div class="compact pp-header-row">
|
||||
<span class="pp-header-label">DISPLAY</span>
|
||||
<span class="panel-divider" aria-hidden="true"></span>
|
||||
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel" data-i18n-title="cockpit.display.collapseTitle">◀</button>
|
||||
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel" data-i18n-title>◀</button>
|
||||
</div>
|
||||
<div class="pp-panel-body" data-rail-scroller>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)" data-i18n-title="cockpit.display.hudToggleTitle">
|
||||
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)" data-i18n-title>
|
||||
<span class="pp-icon">◎</span>
|
||||
<span class="pp-label">HUD</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="hud-layout-row">
|
||||
<span class="pp-slider-mini-label" data-i18n="cockpit.display.hudLayoutLabel">Layout</span>
|
||||
<select id="hud-layout-select" class="pp-select" aria-label="HUD layout" data-i18n-aria-label="cockpit.display.hudLayoutAriaLabel">
|
||||
<option value="tactical" data-i18n="cockpit.display.hudLayoutTactical">Tactical</option>
|
||||
<option value="operator" data-i18n="cockpit.display.hudLayoutOperator">Operator</option>
|
||||
<option value="minimal" data-i18n="cockpit.display.hudLayoutMinimal">Minimal</option>
|
||||
<span class="pp-slider-mini-label" data-i18n>Layout</span>
|
||||
<select id="hud-layout-select" class="pp-select" aria-label="HUD layout" data-i18n-aria-label>
|
||||
<option value="tactical" data-i18n>Tactical</option>
|
||||
<option value="operator" data-i18n>Operator</option>
|
||||
<option value="minimal" data-i18n>Minimal</option>
|
||||
<option value="cyber">Cyber</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -52,39 +52,39 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="detection-toggle" title="Detection Overlay (D)" aria-label="Detection overlay" aria-pressed="false" data-i18n-title="cockpit.display.detectionToggleTitle" data-i18n-aria-label="cockpit.display.detectionAriaLabel">
|
||||
<button class="pp-toggle-btn" id="detection-toggle" title="Detection Overlay (D)" aria-label="Detection overlay" aria-pressed="false" data-i18n-title data-i18n-aria-label>
|
||||
<span class="pp-icon">▣</span>
|
||||
<span class="pp-label" data-i18n="cockpit.display.detectionLabel">DETECT</span>
|
||||
<span class="pp-label" data-i18n>DETECT</span>
|
||||
</button>
|
||||
<div class="pp-slider-row detection-slider-row" id="detection-slider-row">
|
||||
<label class="pp-slider-mini-label" for="detection-density-slider" data-i18n="cockpit.display.densityLabel">Density</label>
|
||||
<input type="range" class="pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" data-i18n-aria-label="cockpit.display.densityAriaLabel" />
|
||||
<label class="pp-slider-mini-label" for="detection-density-slider" data-i18n>Density</label>
|
||||
<input type="range" class="pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="detection-density-value">50%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-allocation-row" id="detection-allocation-row">
|
||||
<span class="pp-slider-mini-label" data-i18n="cockpit.display.allocationLabel">Allocation</span>
|
||||
<div class="pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation" data-i18n-aria-label="cockpit.display.allocationAriaLabel">
|
||||
<button class="pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true" data-i18n="cockpit.display.allocationElastic">Elastic</button>
|
||||
<button class="pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false" data-i18n="cockpit.display.allocationWeighted">Weighted</button>
|
||||
<span class="pp-slider-mini-label" data-i18n>Allocation</span>
|
||||
<div class="pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation" data-i18n-aria-label>
|
||||
<button class="pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true" data-i18n>Elastic</button>
|
||||
<button class="pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false" data-i18n>Weighted</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-fade-row" id="detection-fade-row">
|
||||
<label class="pp-slider-mini-label" for="detection-fade-slider" data-i18n="cockpit.display.fadeLabel">Fade</label>
|
||||
<input type="range" class="pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label="cockpit.display.fadeAriaLabel" data-i18n-title="cockpit.display.fadeTitle" />
|
||||
<label class="pp-slider-mini-label" for="detection-fade-slider" data-i18n>Fade</label>
|
||||
<input type="range" class="pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="detection-fade-value">7%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-opacity-row" id="detection-opacity-row">
|
||||
<label class="pp-slider-mini-label" for="detection-opacity-slider" data-i18n="cockpit.display.outsideLabel">Outside</label>
|
||||
<input type="range" class="pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label="cockpit.display.outsideAriaLabel" data-i18n-title="cockpit.display.outsideTitle" />
|
||||
<label class="pp-slider-mini-label" for="detection-opacity-slider" data-i18n>Outside</label>
|
||||
<input type="range" class="pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="detection-opacity-value">1%</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The one shared preset-parameter surface is inserted here at runtime. -->
|
||||
<div id="param-slider-panel">
|
||||
<div class="param-panel-header">
|
||||
<span class="param-panel-title" data-i18n="cockpit.display.parametersTitle">PARAMETERS</span>
|
||||
<span class="param-panel-title" data-i18n>PARAMETERS</span>
|
||||
<span class="param-panel-divider"></span>
|
||||
<button class="panel-collapse-btn" id="param-panel-collapse-btn" data-collapse-target="param-slider-panel" title="Collapse panel" data-i18n-title="cockpit.display.parametersCollapseTitle">−</button>
|
||||
<button class="panel-collapse-btn" id="param-panel-collapse-btn" data-collapse-target="param-slider-panel" title="Collapse panel" data-i18n-title>−</button>
|
||||
</div>
|
||||
<div id="param-sliders"></div>
|
||||
</div>
|
||||
@@ -93,31 +93,31 @@
|
||||
button ships lit and the mode row ships open — the same markup-carries-the-default
|
||||
convention as #scope-toggle. ui.js re-syncs both from durable layer state, which
|
||||
is what flips them back for a share link or a stored session carrying 3D off. -->
|
||||
<button class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true" title="3D aircraft — flat icons zoomed out, 3D models up close" data-i18n-title="cockpit.display.modelsToggleTitle">
|
||||
<button class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true" title="3D aircraft — flat icons zoomed out, 3D models up close" data-i18n-title>
|
||||
<span class="pp-icon">✈</span>
|
||||
<span class="pp-label">3D</span>
|
||||
</button>
|
||||
<div class="pp-slider-row visible" id="models3d-mode-row">
|
||||
<span class="pp-slider-mini-label" data-i18n="cockpit.display.modelsLabel">Models</span>
|
||||
<div class="pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label="cockpit.display.modelsCoverageAriaLabel">
|
||||
<button class="pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true" data-i18n="cockpit.display.modelsModeProximity">Proximity</button>
|
||||
<button class="pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false" data-i18n="cockpit.display.modelsModeAll">All</button>
|
||||
<span class="pp-slider-mini-label" data-i18n>Models</span>
|
||||
<div class="pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label>
|
||||
<button class="pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true" data-i18n>Proximity</button>
|
||||
<button class="pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false" data-i18n>All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true" data-i18n-title="cockpit.display.scopeToggleTitle">
|
||||
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true" data-i18n-title>
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
|
||||
<span class="pp-label" data-i18n="cockpit.display.scopeLabel">Scope</span>
|
||||
<span class="pp-label" data-i18n>Scope</span>
|
||||
</button>
|
||||
<div class="pp-slider-row visible" id="scope-slider-row">
|
||||
<span class="pp-slider-mini-label" data-i18n="cockpit.display.featherLabel">Feather</span>
|
||||
<span class="pp-slider-mini-label" data-i18n>Feather</span>
|
||||
<!-- Feather starts at an 11% soft scope-mask edge (owner final lock
|
||||
2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings);
|
||||
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
|
||||
softens the black mask edge; label fading is the separate Fade
|
||||
slider. -->
|
||||
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label="cockpit.display.featherAria" data-i18n-title="cockpit.display.featherTitle" />
|
||||
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="scope-feather-value">11%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,35 +148,35 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false" data-i18n-title="cockpit.display.celestialToggleTitle">
|
||||
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false" data-i18n-title>
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
|
||||
<span class="pp-label" data-i18n="cockpit.display.celestialLabel">Celestial</span>
|
||||
<span class="pp-label" data-i18n>Celestial</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="pp-toggle-btn" id="clean-view-toggle" title="Hide UI chrome" data-i18n-title="cockpit.display.cleanViewToggleTitle">
|
||||
<button class="pp-toggle-btn" id="clean-view-toggle" title="Hide UI chrome" data-i18n-title>
|
||||
<span class="pp-icon">□</span>
|
||||
<span class="pp-label" data-i18n="cockpit.display.cleanViewLabel">Clean UI</span>
|
||||
<span class="pp-label" data-i18n>Clean UI</span>
|
||||
</button>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="bloom-toggle" title="Bloom / Glow" data-i18n-title="cockpit.display.bloomToggleTitle">
|
||||
<button class="pp-toggle-btn" id="bloom-toggle" title="Bloom / Glow" data-i18n-title>
|
||||
<span class="pp-icon">✨</span>
|
||||
<span class="pp-label" data-i18n="cockpit.display.bloomLabel">Bloom</span>
|
||||
<span class="pp-label" data-i18n>Bloom</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="bloom-slider-row">
|
||||
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" data-i18n-aria-label="cockpit.display.bloomAria" />
|
||||
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="bloom-intensity-value">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="sharpen-toggle" title="Sharpening" data-i18n-title="cockpit.display.sharpenToggleTitle">
|
||||
<button class="pp-toggle-btn" id="sharpen-toggle" title="Sharpening" data-i18n-title>
|
||||
<span class="pp-icon">🔍</span>
|
||||
<span class="pp-label" data-i18n="cockpit.display.sharpenLabel">Sharpen</span>
|
||||
<span class="pp-label" data-i18n>Sharpen</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="sharpen-slider-row">
|
||||
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" data-i18n-aria-label="cockpit.display.sharpenAria" />
|
||||
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="sharpen-intensity-value">49%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="clean-view-exit" title="Return UI controls" data-i18n-title="cockpit.display.cleanViewExitTitle" data-i18n="cockpit.display.cleanViewExitLabel">EXIT CLEAN VIEW</button>
|
||||
<button id="clean-view-exit" title="Return UI controls" data-i18n-title data-i18n>EXIT CLEAN VIEW</button>
|
||||
@@ -6,6 +6,6 @@
|
||||
<div class="loader-content">
|
||||
<span class="loader-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span>
|
||||
<h2>GOD'S EYE <span class="title-accent">VIEW</span></h2>
|
||||
<p class="loader-status" data-i18n="shell.loading.initialStatus">Initializing photorealistic world...</p>
|
||||
<p class="loader-status" data-i18n>Initializing photorealistic world...</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,9 +4,9 @@
|
||||
<div class="panel-glow"></div>
|
||||
<div class="data-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n="shell.panels.dataLayers">DATA LAYERS</span>
|
||||
<span class="panel-title" data-i18n>DATA LAYERS</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="data-panel" title="Collapse panel" data-i18n-title="shell.panels.collapseTitle">+</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="data-panel" title="Collapse panel" data-i18n-title>+</button>
|
||||
</div>
|
||||
<div id="data-toggles" class="data-toggle-list"></div>
|
||||
</div>
|
||||
@@ -17,56 +17,56 @@
|
||||
<div class="panel-glow"></div>
|
||||
<div class="cctv-panel-inner" data-rail-scroller>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n="layers.cctv.panelTitle">CCTV</span>
|
||||
<span class="panel-title" data-i18n>CCTV</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel" data-i18n-title="layers.cctv.collapseTitle">+</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel" data-i18n-title>+</button>
|
||||
</div>
|
||||
<div class="cyber-panel-body" data-rail-scroller>
|
||||
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
|
||||
<img id="cctv-frame" alt="CCTV feed frame" />
|
||||
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
|
||||
</div>
|
||||
<div id="cctv-source-badge" role="status" aria-live="polite" data-i18n="layers.cctv.sourceUnknown">SOURCE · UNKNOWN</div>
|
||||
<div id="cctv-meta" data-i18n="layers.cctv.metaIdle">Enable CCTV to load camera intersections</div>
|
||||
<div id="cctv-source-badge" role="status" aria-live="polite" data-i18n>SOURCE · UNKNOWN</div>
|
||||
<div id="cctv-meta" data-i18n>Enable CCTV to load camera intersections</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-enable-btn" class="scene-btn" data-i18n="layers.cctv.toggleOff">CCTV OFF</button>
|
||||
<button id="cctv-nearest-btn" class="scene-btn" data-i18n="layers.cctv.nearest">NEAREST</button>
|
||||
<button id="cctv-enable-btn" class="scene-btn" data-i18n>CCTV OFF</button>
|
||||
<button id="cctv-nearest-btn" class="scene-btn" data-i18n>NEAREST</button>
|
||||
</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-prev-btn" class="scene-btn" data-i18n="layers.cctv.prev">PREV</button>
|
||||
<select id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label="layers.cctv.cameraAriaLabel"></select>
|
||||
<button id="cctv-next-btn" class="scene-btn" data-i18n="layers.cctv.next">NEXT</button>
|
||||
<button id="cctv-prev-btn" class="scene-btn" data-i18n>PREV</button>
|
||||
<select id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label></select>
|
||||
<button id="cctv-next-btn" class="scene-btn" data-i18n>NEXT</button>
|
||||
</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-focus-btn" class="scene-btn" data-i18n="layers.cctv.focus">FOCUS</button>
|
||||
<button id="cctv-coverage-btn" class="scene-btn" data-i18n="layers.cctv.coverageOff">COVERAGE OFF</button>
|
||||
<button id="cctv-auto-hop-btn" class="scene-btn" data-i18n="layers.cctv.autoHopOff">AUTO HOP OFF</button>
|
||||
<button id="cctv-focus-btn" class="scene-btn" data-i18n>FOCUS</button>
|
||||
<button id="cctv-coverage-btn" class="scene-btn" data-i18n>COVERAGE OFF</button>
|
||||
<button id="cctv-auto-hop-btn" class="scene-btn" data-i18n>AUTO HOP OFF</button>
|
||||
</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-projection-btn" class="scene-btn" data-i18n="layers.cctv.projectionOn">PROJECTION ON</button>
|
||||
<button id="cctv-projection-btn" class="scene-btn" data-i18n>PROJECTION ON</button>
|
||||
<span id="cctv-quality-chip">CAL · --</span>
|
||||
</div>
|
||||
<div class="cctv-calibration-block">
|
||||
<div class="cctv-calibration-header">
|
||||
<div class="cctv-summary-label" data-i18n="layers.cctv.calibrationLabel">CALIBRATION</div>
|
||||
<button id="cctv-adjust-btn" class="scene-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV" data-i18n-title="layers.cctv.adjustTitle" data-i18n="layers.cctv.adjustLabel">ADJUST</button>
|
||||
<div class="cctv-summary-label" data-i18n>CALIBRATION</div>
|
||||
<button id="cctv-adjust-btn" class="scene-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV" data-i18n-title data-i18n>ADJUST</button>
|
||||
</div>
|
||||
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type" data-i18n-aria-label="layers.cctv.calPoseAriaLabel">
|
||||
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type" data-i18n-title="layers.cctv.calHeadingTitle">HDG --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type" data-i18n-title="layers.cctv.calPitchTitle">PITCH --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type" data-i18n-title="layers.cctv.calFovTitle">FOV --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type" data-i18n-title="layers.cctv.calRangeTitle">RANGE --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type" data-i18n-title="layers.cctv.calHeightTitle">HGT --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="north" title="North offset from catalog position (m) — click to type" data-i18n-title="layers.cctv.calNorthTitle">ΔN --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type" data-i18n-title="layers.cctv.calEastTitle">ΔE --</button>
|
||||
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type" data-i18n-aria-label>
|
||||
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type" data-i18n-title>HDG --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type" data-i18n-title>PITCH --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type" data-i18n-title>FOV --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type" data-i18n-title>RANGE --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type" data-i18n-title>HGT --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="north" title="North offset from catalog position (m) — click to type" data-i18n-title>ΔN --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type" data-i18n-title>ΔE --</button>
|
||||
</div>
|
||||
<div class="cctv-controls cctv-calibration-actions">
|
||||
<button id="cctv-calib-save-btn" class="scene-btn" data-i18n="layers.cctv.saveCal">SAVE CAL</button>
|
||||
<button id="cctv-calib-reset-btn" class="scene-btn" data-i18n="layers.cctv.resetCal">RESET CAL</button>
|
||||
<button id="cctv-calib-save-btn" class="scene-btn" data-i18n>SAVE CAL</button>
|
||||
<button id="cctv-calib-reset-btn" class="scene-btn" data-i18n>RESET CAL</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cctv-summary-label" data-i18n="layers.cctv.summaryLabel">SCENE SUMMARY</div>
|
||||
<div id="cctv-summary" data-i18n="layers.cctv.summaryIdle">Enable CCTV to start camera-linked intelligence summaries.</div>
|
||||
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
|
||||
<div id="cctv-summary" data-i18n>Enable CCTV to start camera-linked intelligence summaries.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,35 +145,35 @@
|
||||
<div class="panel-glow"></div>
|
||||
<div class="scene-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n="setup.scenes.panelTitle">SCENES</span>
|
||||
<span class="panel-title" data-i18n>SCENES</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel" data-i18n-title="setup.scenes.collapseTitle">+</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel" data-i18n-title>+</button>
|
||||
</div>
|
||||
<div class="scene-controls">
|
||||
<select id="scene-select" aria-label="Scene recipe" data-i18n-aria-label="setup.scenes.recipeAriaLabel"></select>
|
||||
<button id="scene-new-btn" class="scene-btn" data-i18n="setup.scenes.new">NEW</button>
|
||||
<button id="scene-delete-btn" class="scene-btn scene-btn-danger" data-i18n="setup.scenes.delete">DEL</button>
|
||||
<select id="scene-select" aria-label="Scene recipe" data-i18n-aria-label></select>
|
||||
<button id="scene-new-btn" class="scene-btn" data-i18n>NEW</button>
|
||||
<button id="scene-delete-btn" class="scene-btn scene-btn-danger" data-i18n>DEL</button>
|
||||
</div>
|
||||
<div class="scene-controls">
|
||||
<button id="scene-capture-btn" class="scene-btn" data-i18n="setup.scenes.capture">CAPTURE SHOT</button>
|
||||
<button id="scene-update-shot-btn" class="scene-btn" data-i18n="setup.scenes.updateShot">UPDATE SHOT</button>
|
||||
<button id="scene-capture-btn" class="scene-btn" data-i18n>CAPTURE SHOT</button>
|
||||
<button id="scene-update-shot-btn" class="scene-btn" data-i18n>UPDATE SHOT</button>
|
||||
</div>
|
||||
<div id="scene-shot-list" class="scene-shot-list"></div>
|
||||
<div class="scene-controls">
|
||||
<button id="scene-start-btn" class="scene-btn" data-i18n="setup.scenes.start">START</button>
|
||||
<button id="scene-stop-btn" class="scene-btn scene-btn-danger" data-i18n="setup.scenes.stop">STOP</button>
|
||||
<button id="scene-next-btn" class="scene-btn" data-i18n="setup.scenes.next">NEXT</button>
|
||||
<button id="scene-start-btn" class="scene-btn" data-i18n>START</button>
|
||||
<button id="scene-stop-btn" class="scene-btn scene-btn-danger" data-i18n>STOP</button>
|
||||
<button id="scene-next-btn" class="scene-btn" data-i18n>NEXT</button>
|
||||
</div>
|
||||
<div class="scene-controls">
|
||||
<button id="scene-export-btn" class="scene-btn" data-i18n="setup.scenes.exportPresets">EXPORT PRESETS</button>
|
||||
<button id="scene-import-btn" class="scene-btn" data-i18n="setup.scenes.import">IMPORT</button>
|
||||
<button id="scene-download-btn" class="scene-btn" data-i18n="setup.scenes.runLog">RUN LOG</button>
|
||||
<button id="scene-export-btn" class="scene-btn" data-i18n>EXPORT PRESETS</button>
|
||||
<button id="scene-import-btn" class="scene-btn" data-i18n>IMPORT</button>
|
||||
<button id="scene-download-btn" class="scene-btn" data-i18n>RUN LOG</button>
|
||||
<input type="file" id="scene-import-file" accept="application/json,.json" hidden />
|
||||
</div>
|
||||
<div class="scene-progress">
|
||||
<div id="scene-progress-fill">0%</div>
|
||||
</div>
|
||||
<div id="scene-status" data-i18n="setup.scenes.statusReady">Ready</div>
|
||||
<div id="scene-status" data-i18n>Ready</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user