Merge remote-tracking branch 'origin/main' into german/hal-642-ja

# Conflicts:
#	docs/CURRENT-STATE.md
#	docs/TRANSLATORS.md
#	scripts/package-boundaries.json
#	src/i18n/i18n.test.mjs
#	src/i18n/index.js
#	src/i18n/locale.js
#	src/i18n/same-as-english.json
#	src/layers/cctv/presentation.js
#	src/ui/localePicker.test.mjs
This commit is contained in:
German Khokhlov committed 2026-10-29 19:47:54 +04:00
commit 812308d0ee
101 files changed
+17490 -332

No files matched your search

+3 -3
View File
@@ -61,7 +61,7 @@ Start with the included data sources, then add your own. Each layer is a separat
- **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click.
- **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes.
- **🛫 3D hangar:** Real per-class aircraft models — 787, ATR-72, Citation, Bell 206, MQ-9 — and a tracked contact swaps from glyph to 3D model as you close in.
- **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir, Snow.
- **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir.
- **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view.
- **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry.
- **🌐 Global Context:** Stage the full situational picture with one switch — and get your exact view back when you leave.
@@ -294,14 +294,14 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
![Tracking the ISS along its orbital path as it crosses over Ukraine](docs/media/14-iss-over-ukraine.gif)
6. **Switch the optics.** Tap `1`–`7` — CRT, NVG, FLIR — and the whole live planet re-renders through a different sensor.
6. **Switch the optics.** Tap `1`–`6` — CRT, NVG, FLIR — and the whole live planet re-renders through a different sensor.
![Cycling a dense live globe through CRT, FLIR, and NVG in one continuous view](docs/media/01-style-sweep.gif)
7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_
8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_
**Keyboard:** `1`–`7` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
---
+4
View File
@@ -0,0 +1,4 @@
- Remove the Snow visual preset. Visual Presets, keyboard shortcuts, the
Cockpit vision carousel and the visual-style voice action now offer Normal,
CRT, NVG, FLIR, Anime and Noir; share links and scene shots that name Snow
open on Normal (asturksever, #1468).
+3
View File
@@ -0,0 +1,3 @@
- Add Ukrainian (uk) to the language picker: the whole interface in Ukrainian,
with its four plural forms and Cyrillic HUD units. Based on the Ukrainian
translation by @meirm (#312) (German, @gekh, #1470).
+3
View File
@@ -0,0 +1,3 @@
- Add Italian (it) to the language picker: the whole interface in Italian,
with Italian plural forms and labels shortened to fit the panels. Based on
the translation by @abiyeenzo (#912) (German, @gekh, #1473).
+4
View File
@@ -0,0 +1,4 @@
- Add Brazilian Portuguese (pt) to the language picker, with every screen
translated and Portuguese plural forms. The CCTV summary line now names its
calibration, source and view in the page language. Based on translations by
@abiyeenzo (#909) (German, @gekh, #1475).
+4
View File
@@ -0,0 +1,4 @@
- Add French (fr) to the language picker, with French typography and plural
forms. With nine languages the picker now shows its search field. Based on
the translation by @abiyeenzo (#907), checked against @meirm's French
catalog (#312) (German, @gekh, #1476).
+3
View File
@@ -0,0 +1,3 @@
- Add German (de) to the language picker, with a full translation of the
interface and a German style guide in docs/translations/de.md. Based on
translations by @abiyeenzo (#908) (German, @gekh, #1477).
+7
View File
@@ -0,0 +1,7 @@
- Let the scene idle with AIS vessels enabled. Vessels no longer hold the
continuous render loop; they request frames for new data and selection, one
trailing frame after the camera stops, and hold the loop only while focus
emphasis animates or a vessel stays dimmed under a tracked target. A parked
vessels-only scene went from continuous rendering to 0 frames in 5 s. While
anything is tracked, the vessel focus pass skips hidden sprites at rest:
about 4 ms to 1.2 ms per pass over 12,000 vessels (waar19, #871).
+21 -9
View File
@@ -1392,8 +1392,10 @@ and sharpen. Display supplies actions and renders settings; effect execution has
no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
Stopping the controller revokes animation before asynchronous UI teardown; final
destruction removes its stages and restores the borrowed bloom configuration.
Snow's sideways sway stays within a fixed range at any session age; Wind scales
its amplitude.
Snow is no longer a visual preset. A share link that names it, in either the
long or compact form, opens on Normal and drops the Snow shader parameters. A
view passed to embed mode or `show_in_gods_eye_view`, and a saved scene shot,
that names Snow also open on Normal.
Sharpen runs only when enabled with intensity above 0.001. At slider zero its
stage is disabled; raising the slider resumes it when sharpen is enabled.
@@ -2286,6 +2288,16 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged.
> SCOPE toggle + FEATHER slider, hash keys `sc`/`scf`) — it is NOT the
> six zero-intensity style stages anymore (those are disabled; see the
> history note in `_initStages`). Hidden tabs stop the render loop.
> AIS vessels no longer hold the loop while enabled (2026-09-30): vessels move
> only when a poll lands, so the layer requests a frame for every forced pass
> (new data, selection), leaves one trailing frame when the camera moved inside
> the 800 ms visibility cadence, and holds `ais-vessels` only while focus
> emphasis animates or any vessel stays dimmed under a live focus target. The
> trailing frame goes through the layer's injectable runtime clock and is
> cancelled on disable, destroy and reset (`renderGovernor.test.mjs` pins the
> no-self-wake invariant). The 80 ms focus pass also skips hidden sprites whose
> focus state is at rest (`spriteFocusAtRest`), since advancing them changes
> nothing; with a 12k worldwide feed that is most of the fleet.
> The scope's OUTSIDE terminus is **altitude-adaptive** (2026-08-17): 0.94
> at/above **10 Mm**, so
> faint stars survive in the corners of a TRUE full-globe view, fading quickly
@@ -2821,7 +2833,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> failed searches leave the current camera owner untouched, and Context Focus
> can return to the preserved Contact. Focus also restores the selected
> aircraft's canonical follow frame after a manual zoom-away.
> Visual presets retain the order Normal, CRT, NVG, FLIR, Anime, Noir, Snow.
> Visual presets retain the order Normal, CRT, NVG, FLIR, Anime, Noir.
> Configurable preset selection—including same-style reselection—opens the
> shared Parameters surface directly below Detection and scrolls it into
> view; share-link restoration
@@ -2873,7 +2885,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> Inside Cockpit, the focused summary card is titled Contact in both visible
> copy and its accessible control labels.
> The top-center Cockpit vision cycle is the fixed, duplicate-free sequence
> Normal, CRT, NVG, FLIR, Anime, Noir, and Snow. Entry selects the map preset
> Normal, CRT, NVG, FLIR, Anime, and Noir. Entry selects the map preset
> already in use, so a FLIR map opens on the existing FLIR item while
> Normal remains available exactly once. NONE is not offered. Cockpit choices
> temporarily override the map preset, while Exit Cockpit and Reset both restore
@@ -3772,7 +3784,7 @@ test:track` 43 tracking invariants · headless QA harnesses under
### Interface localization (October 2026)
Shipped locales: **en** (source and fallback), **es**, **ja**, **ru**, **zh-CN** and **zh-Hant**. The code lives in
Shipped locales: **en** (source and fallback), **de**, **es**, **fr**, **it**, **ja**, **pt** (Brazilian Portuguese), **ru**, **uk**, **zh-CN** and **zh-Hant**. The code lives in
`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow:
[`docs/TRANSLATORS.md`](./TRANSLATORS.md).
@@ -3783,7 +3795,7 @@ Shipped locales: **en** (source and fallback), **es**, **ja**, **ru**, **zh-CN**
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=` → stored choice (`gev:locale:v1`) →
`navigator.languages` → English. Chinese tags default to `zh-CN`; Traditional Chinese tags
`navigator.languages` → English. Any `pt` tag (`pt-BR`, `pt-PT`) selects `pt`. Chinese tags default to `zh-CN`; Traditional Chinese tags
select `zh-Hant`. Explicit `Hans` selects `zh-CN` regardless of region. `?lang=` is not stored, and share links drop
it, so a recipient keeps their own language. The globe picker in the
bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops
@@ -5162,8 +5174,8 @@ and unreachable upstream (502/504) separately from road geometry.
first, activates the Contacts sub-view, and returns the settled 250 km window.
Its `aircraft` count is the exact civilian-plus-military total when both feeds
can answer, or `unknown` when either component is unavailable.
- Cockpit's top vision switch cycles all seven map looks: Normal, CRT, NVG,
FLIR, Anime, Noir, and Snow. Entry selects the matching map style without adding
- Cockpit's top vision switch cycles all six map looks: Normal, CRT, NVG,
FLIR, Anime, and Noir. Entry selects the matching map style without adding
a duplicate carousel item. There is no empty `NONE` entry.
### Live AIS Vessels (June 2026)
@@ -5649,7 +5661,7 @@ apply to `public/` assets or built-preview serving.
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Scenes keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
- Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`.
- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition.
- **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown.
+1 -1
View File
@@ -35,7 +35,7 @@ A module the server, tools or other portable code also imports must not load the
<input placeholder="Search any location..." data-i18n-placeholder />
```
`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. Markup is translated once at boot, and switching language reloads the page. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language picker asks before reloading.
`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. `data-i18n-context="radio tag"` gives the element's text a context, the catalog entry `radio tag|All`, as `t('All', { context: 'radio tag' })` does in code. Markup is translated once at boot, and switching language reloads the page. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language picker asks before reloading.
## Rules
+2 -1
View File
@@ -97,7 +97,8 @@ operational sample contained 3,909 observations and two selected labels, but its
live aircraft and traffic rows were empty, so it remains a limited stress case.
Snow, Noir, dense detection, and text-heavy combined layers are the clearest
controlled comparison points for later optimization work.
controlled comparison points for later optimization work. Snow has since been
retired as a visual preset; its row records the measurement as taken.
## Keyed live sources
+33 -31
View File
@@ -90,35 +90,35 @@ probability of about 255 in 256 (many of them already fail as base64url).
## Field registry (top level)
| # | State key | Type | Default | Encoding |
| --- | ---------------------------- | ----- | ----------- | ------------------------------------------------------- |
| 1 | `view.kind` | enum | see below | 0 place, 1 thing |
| 2 | `lat` | coord | 0 | zigzag of degrees × 10⁴ |
| 3 | `lon` | coord | 0 | zigzag of degrees × 10⁴ |
| 4 | `alt` | sint | 800 | whole metres |
| 5 | `heading` | angle | 0 | whole degrees, see angles |
| 6 | `pitch` | sint | −35 | whole degrees |
| 7 | `roll` | angle | 0 | whole degrees |
| 8 | `sharedAtMs` | epoch | none | zigzag seconds since 2026-01-01T00:00:00Z |
| 9 | booleans | flags | 0 | bit set when the value differs from default |
| 10 | `layerState.enabledLayerIds` | LEN | none | layer bitset, see layers |
| 11 | `layerState.options` | LEN | none | packed option triples, see layers |
| 12 | `panelState` | LEN | none | packed panel entries, see panels |
| 13 | thing | LEN | none | sub-message, see thing views |
| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow |
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm |
| 16 | `hudVariant` | enum | style | tactical, operator, minimal, cyber |
| 17 | `bloomIntensity` | uint | style | |
| 18 | `bloomVersion` | uint | 2 | |
| 19 | `sharpenIntensity` | uint | style | |
| 20 | `detectionMode` | enum | style | OFF, SPARSE, BALANCED, DENSE |
| 21 | `detectionDensity` | uint | style | one of 0, 25, 50, 75, 100 |
| 22 | `detectionAllocation` | enum | style | ELASTIC, WEIGHTED |
| 23 | `detectionFadePct` | uint | style | 0–40 |
| 24 | `detectionOutsideOpacityPct` | uint | style | 0–100 |
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
| # | State key | Type | Default | Encoding |
| --- | ---------------------------- | ----- | ----------- | ----------------------------------------------------------------- |
| 1 | `view.kind` | enum | see below | 0 place, 1 thing |
| 2 | `lat` | coord | 0 | zigzag of degrees × 10⁴ |
| 3 | `lon` | coord | 0 | zigzag of degrees × 10⁴ |
| 4 | `alt` | sint | 800 | whole metres |
| 5 | `heading` | angle | 0 | whole degrees, see angles |
| 6 | `pitch` | sint | −35 | whole degrees |
| 7 | `roll` | angle | 0 | whole degrees |
| 8 | `sharedAtMs` | epoch | none | zigzag seconds since 2026-01-01T00:00:00Z |
| 9 | booleans | flags | 0 | bit set when the value differs from default |
| 10 | `layerState.enabledLayerIds` | LEN | none | layer bitset, see layers |
| 11 | `layerState.options` | LEN | none | packed option triples, see layers |
| 12 | `panelState` | LEN | none | packed panel entries, see panels |
| 13 | thing | LEN | none | sub-message, see thing views |
| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow (retired) |
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm |
| 16 | `hudVariant` | enum | style | tactical, operator, minimal, cyber |
| 17 | `bloomIntensity` | uint | style | |
| 18 | `bloomVersion` | uint | 2 | |
| 19 | `sharpenIntensity` | uint | style | |
| 20 | `detectionMode` | enum | style | OFF, SPARSE, BALANCED, DENSE |
| 21 | `detectionDensity` | uint | style | one of 0, 25, 50, 75, 100 |
| 22 | `detectionAllocation` | enum | style | ELASTIC, WEIGHTED |
| 23 | `detectionFadePct` | uint | style | 0–40 |
| 24 | `detectionOutsideOpacityPct` | uint | style | 0–100 |
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
so v1 never writes it; a later view kind writes its code.
@@ -163,7 +163,7 @@ for it.) In v1:
| thermal | sensitivity 0.85, bloom 0.2, mode 0.33, pixelation 1, palette 0 |
| anime | saturation 1, edgeThick 0.5 |
| noir | contrastAmt 1.2, grainAmt 0.5, vignetteAmt 0.5 |
| snow | density 0.6, wind 0.5 |
| snow | retired: decodes as normal; its parameters are dropped |
These are frozen like every other default: the codec reads them from the
app's tables today, and a test pins them, so if a table moves the old values
@@ -342,7 +342,9 @@ the app that can open it.
values in the codec (see Style defaults). The same applies
to `SHARE_VIEW_LAYER_OPTION_ABSENT` and the panel defaults.
4. **Codes are append-only.** Enum lists, entity kinds, flag bits, panel
numbers and style parameter tokens only grow.
numbers and style parameter tokens only grow. A style the app no longer
offers keeps its code: `snow` (6) is retired, so a link that names it
opens as Normal and the parameters written for it are dropped.
5. **Add, don't change.** A new setting takes the next free field number
(28 onwards; fields 1–15 have one-byte tags and are all taken). A new
boolean takes the next flag bit instead, which costs nothing when it is at
+2 -1
View File
@@ -92,7 +92,8 @@ pitch), data layers, visual style, map imagery, and an aircraft, military
aircraft or satellite to follow. `createView` builds and bounds one,
`viewToParams` and `viewFromParams` write and read it in the share-link
format the app restores, and `viewUrl` gives the address that opens it. The
style names are the ones share links use. Ships cannot be followed from a
style names are the ones share links use; a retired style (`snow`) is read
as `normal`. Ships cannot be followed from a
link yet. An aircraft's `follow` may ask for its cockpit view
(`cockpit: true`); links cannot carry that and open the app following it.
+2 -2
View File
@@ -1,6 +1,6 @@
# Translating God's Eye View
Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Japanese** (`ja`), **Russian** (`ru`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
Shipped languages: **English** (source and fallback), **German** (`de`), **Spanish** (`es`), **French** (`fr`), **Italian** (`it`), **Japanese** (`ja`), **Brazilian Portuguese** (`pt`), **Russian** (`ru`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
## How it works
@@ -63,4 +63,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth
## Language notes
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Spanish](./translations/es.md), [Japanese](./translations/ja.md), [Russian](./translations/ru.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [German](./translations/de.md), [Spanish](./translations/es.md), [French](./translations/fr.md), [Italian](./translations/it.md), [Japanese](./translations/ja.md), [Brazilian Portuguese](./translations/pt.md), [Russian](./translations/ru.md), [Ukrainian](./translations/uk.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
+108
View File
@@ -0,0 +1,108 @@
# German (`de`)
Catalog: `src/i18n/locales/de.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation source: abiyeenzo (#908).
## Style
- Standard German that reads naturally in Germany, Austria and Switzerland; ß where it belongs (Straße, schließen), no Swiss ss.
- No direct address: neither «Sie» nor «du». Actions and hints use the neutral infinitive: «Erneut versuchen», «Kamera anklicken zum Aktivieren», «Zuerst eine Aufnahme wählen», «Mikrofonzugriff erlauben und erneut versuchen». "Your" becomes the article or «eigene»: «Eigener RTL-SDR-Empfänger», «Die IP-Adresse ist für diesen Sender sichtbar».
- Nouns are capitalized, as German requires; otherwise sentence case, not Title Case: "Live Flights" → «Live-Flüge», "Provider Settings" → «Anbieter-Einstellungen». UPPERCASE English stays UPPERCASE: «LIVE-DATEN AUS», «KEINE ENTWARNUNG». In UPPERCASE, ß is written SS: «SCHLIESSEN», «STRASSENEBENE».
- Compounds are written as one word or with a hyphen next to a name, code or English term: «Datenebene», «Wetterkacheln», «TomTom-Schlüssel», «AIS-Schiffe», «Street-View-Fallback». Do not split them with spaces.
- Short labels, chips and HUD readouts stay short. German runs about a third longer than English, so prefer a short word or a standard abbreviation with a period over a wrapped line: REICHW., GESCHW., NIEDERSCHL., SONST., NACHR., VERST., VGL., KAL., KOMM. (a bare «KOMM» reads as the imperative)
- The avionics codes HDG, BRG, CRS, DEST, SPD, ALT, GS, TRK, V/S, FOV and SQUAWK stay in English, as in the cockpit. Words used in a sentence are translated: heading → «Kurs», bearing → «Peilung», range → «Reichweite».
- A dash between clauses is a spaced en dash « – »; a colon is fine where it explains: «Feed ausgefallen – neue Versuche in größeren Abständen», «SIMULIERT: Verkehrsdienst nicht erreichbar». Quotes are „…“.
- Status and waiting messages use the neutral infinitive too: «Warten auf Aktualisierung», not «Warte auf …». Reasons inserted after a dash stay lower case: «Szene nicht gespeichert – das Projekt ist größer als 5 MiB».
- A layer named in a sentence takes its panel name in quotes: «Die Ebene „Live-Flüge“ ist nicht aktiviert», «die Ebene „Live-Flüge“ lädt Daten für den jeweiligen Bildausschnitt».
- Rolling time windows say their length, so they cannot read as the previous calendar period: LAST YEAR → «LETZTE 12 MONATE», LAST MONTH → «LETZTE 30 TAGE».
- Keyboard keys are named as on German keyboards where they differ: Leertaste, Rücktaste, Umschalt; Esc, Enter, Tab and letter keys stay.
- Decimal comma in fixed text: «M4,4», «28,2710° N». Thousands separator is a period: «1.000 km». Formatted numbers come from `Intl` and are already German.
- Units: km, m, km/h, m/s, kn (knots, also in the cockpit), ft (feet), hPa, MW, h, min, s, T (days, in short readouts); in UPPERCASE text KM, KM/H, KN, FT, MIN, H, T («VOR {count} H»). A space between number and unit, in compact readouts too: «{value} m», «{count} min», «50 %», «{speed} KN», «{count} / 30 T». `°` sticks to the number.
- "ago" goes before the time: «vor {count} min», «vor {value}». When a placeholder already holds a localized "ago" text (`{age}` in `STALE · cached {age}`, `{ago}`), do not add «vor» again.
- Compass points use O for Ost: N, NNO, NO, ONO, O, OSO, SO, SSO, S, SSW, SW, WSW, W, WNW, NW, NNW; hemisphere letters N, S, O, W.
- Routing: direction words are lowercase adverbs (links, leicht rechts, scharf links, geradeaus), so a sentence never starts with one: «Auf {road} {direction} abbiegen», «Hier {direction} abbiegen», «Weiter {direction}». «Hier», not «Dann»: the step card already says «dann {leg}» and «Nächster Schritt: …». At a fork the verb is «weiter», which also works with «geradeaus»: «An der Gabelung {direction} weiter». Roundabout exits are ordinals with a period: «die {exit} Ausfahrt» → «die 2. Ausfahrt». A lane is named by where it leads, not by its position: «Die geradeaus führende Spur benutzen», «die leicht nach links führende», «die scharf nach rechts führende» (not «halblinke» or «äußerst linke»).
## Plurals
A `tn()` entry needs two forms. `Intl.PluralRules('de')` picks:
| Form | Counts | Example |
| ------- | --------------------------- | --------- |
| `one` | 1 | 1 Kamera |
| `other` | everything else: 0, 2, 1,5… | 3 Kameras |
Keep the verb in agreement: «{count} Station meldet» / «{count} Stationen melden». Abbreviated units and codes do not change: «{count} NACHR.», «{count} EMPFANGEN».
## Glossary
| English | Deutsch |
| ---------------------------------------------- | ---------------------------------------------------------------------- |
| layer / data layer | Ebene / Datenebene |
| feed / source / data source / input (cockpit) | Feed / Quelle / Datenquelle / Quelle |
| LIVE (status) / live data | LIVE / Live-Daten |
| SIMULATED | SIMULIERT |
| stale / degraded / unavailable | veraltet / eingeschränkt / nicht verfügbar |
| retry / retrying / retrying slowly | neuer Versuch / erneut versuchen / neue Versuche in größeren Abständen |
| fallback | Fallback |
| key (API key) / POWER UP / POWERED UP | Schlüssel / STARTKLAR / HOCHGEFAHREN |
| Provider Settings | Anbieter-Einstellungen |
| ON / OFF (state chips) | EIN / AUS |
| enable / disable | aktivieren / deaktivieren |
| globe / map / map source / map stack / basemap | Globus / Karte / Kartenquelle / Kartenstapel / Basiskarte |
| aircraft / flight / military flights | Luftfahrzeug (HUD and short labels: Flugzeug) / Flug / Militärflüge |
| vessel, ship | Schiff |
| satellite / shell (Starlink) / DENSE | Satellit / Schale / DICHT |
| launch (rocket) / launch site / space mission | Start / Startplatz / Raummission |
| ascent / replay / payload / stage / downrange | Aufstieg / Replay, Wiedergabe / Nutzlast / Stufe / Downrange |
| contact / tracking | Kontakt / Verfolgung |
| installation / site | Anlage / Standort |
| mapped / observed | kartiert / beobachtet (Bhote chip OBSERVED IMAGERY: REALE BILDDATEN) |
| camera / CCTV camera / camera badge | Kamera / CCTV-Kamera / Kamera-Markierung |
| earthquake / active fire / fire perimeter | Erdbeben / aktiver Brand / Brandgrenze |
| cyclone / advisory (NHC/CPHC) / cone | Wirbelsturm / Lagebericht (not «Warnung») / Kegel |
| issued (advisory, forecast) | ausgegeben |
| radio station | Sender |
| broadcast FM | UKW |
| road / flow (traffic) / flow coverage | Straße / Verkehrsfluss / Live-Abdeckung |
| transit / bike share | ÖPNV / Bikesharing |
| scene / shot / scene recipe | Szene / Aufnahme / Szenenrezept |
| capture shot / camera move | Aufnahme speichern / Kameraflug |
| anchor / data pack / bundle | Anker / Datenpaket / Bundle |
| story beat / evidence beat | Story-Schritt / Beweisschritt |
| briefing / panel / dock | Briefing / Bereich / Dock |
| cockpit | Cockpit |
| preset / visual preset | Preset / visuelles Preset |
| Scope (circular viewport mask) / keyhole | Scope / Schlüsselloch |
| feather / fade | Weichzeichnung / Ausblenden |
| overlay / detection overlay | Overlay / Erkennungs-Overlay |
| Clean UI | Reine Ansicht |
| Night vision / Thermal / Snow (cockpit styles) | Nachtsicht / Wärmebild / Schnee |
| heading / altitude / ground speed | Kurs / Höhe / Geschwindigkeit über Grund (short: GESCHW. ÜBER GRUND) |
| HDG / BRG / CRS / DEST / SPD / FOV | unchanged |
| RANGE / PITCH / HGT (CCTV calibration chips) | REICHW. / NEIGUNG / HÖHE |
| PREV / NEXT | ZURÜCK / WEITER |
| RESUME (radio transport button) | FORTSETZEN (not «WEITER», which is NEXT in the same row) |
| nearest (spatial) | nächstgelegen (always; «nächste» is NEXT) |
| expand / collapse (sections) | ausklappen / einklappen |
| L / R (relative bearing) | L / R |
| SIG / NEWS / LOCAL (briefing tabs) | SIG / NEWS / LOKAL |
| NOT AN ALL-CLEAR | KEINE ENTWARNUNG |
| Street Level / Street View (Google) | Straßenebene / Street View |
| Recent Imagery / box / pin (a day) | Aktuelle Bilddaten / Bereich / fixieren |
| frame (bring into view) | ins Bild nehmen |
| mark (map annotation) / outline | Markierung / Umriss |
| voice (control) / voice session | Sprachsteuerung / Sprachsitzung |
| YOU / THIS / HERE (voice card labels) | EINGABE / DIES / HIER |
| click / click to … | anklicken / … klicken |
| tap (touch) | Antippen (not «Tipp», which is a hint) |
| press / tap a key | Taste drücken / kurz drücken (never «tippen» for keys) |
| tilted view | Schrägansicht |
| card (host card) / voice card | Eintrag (in hints), Infokarte / Sprachkarte |
| label (on the map) / clear (switch off layers) | Beschriftung / ausschalten (not «löschen») |
| station tag (radio filter) | Kategorie |
| DIRECTORY BAND / {category} BAND (radio dial) | SENDERSKALA / SENDERSKALA · {category} (not «-BAND»: «ROCK-BAND») |
| MIC (dock button) | MIKRO |
| frame (weather, video) / image | Frame / Bild |
| collapse / landslide (Bhote Koshi) | Erdrutsch (not «Abbruch», which reads as "cancel") |
| website (radio station) | Website (SENDER-WEBSITE; «Standort» is a physical site) |
+105
View File
@@ -0,0 +1,105 @@
# French (`fr`)
Catalog: `src/i18n/locales/fr.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation source: abiyeenzo (#907); the French part of meirm's #312 was used for comparison.
## Style
- Standard French that reads naturally in France, Belgium, Switzerland and Canada; avoid regionalisms.
- Address the user with "vous" imperatives: « Cliquez », « Activez », « Réessayez ». Tooltips that name an action use the infinitive: « Réduire le panneau », « Réinitialiser la caméra ». UPPERCASE HUD instructions also use the infinitive: « CLIQUER SUR UNE CAMÉRA POUR L'ACTIVER ».
- Sentence case, not Title Case: "Live Flights" → « Vols en direct ». UPPERCASE English stays UPPERCASE, accents included: « ÉTAT », « PRÉC. ».
- Typography: a narrow no-break space (U+202F) goes before `:` `;` `?` `!` and `%`, and inside « » quotes: `Supprimer le plan « {shot} » ?`. It keeps the sign on the same line as the word before it. Do not type a plain space there. The same narrow no-break space separates thousands in a number written into the text: « 1 000 km ». Ellipsis is `…`, not `...`. Dashes keep their spaces: « flux indisponible — réessayez ».
- Short labels, chips and HUD readouts stay short. A standard abbreviation with a period beats a wrapped line: PRÉC., SUIV., RÉINIT., ENREG., INDISP., DIST.
- The HUD codes HDG, BRG, CRS, FOV, DEST, SPD and ALT stay in English everywhere, as in cockpit avionics. Words are translated: RANGE → « PORTÉE », heading in a sentence → « cap », ground-track course → « route » (« ROUTE 270° »).
- ON and OFF stay as they are on HUD toggles and chips (« CCTV ON », « PROJECTION OFF »); in a sentence use « activé » / « désactivé ».
- Keyboard keys stay in English: Space, Esc, Enter, Tab, Shift, Backspace, H, 1–9 (« Maintenez Space pour parler », « ESC pour fermer »).
- Units: km, m, km/h, m/s, nd (knots), ft (feet), h, min, s, with a space before the unit: « 250 km », « il y a 5 min »; in UPPERCASE text KM, KM/S, ND, FT, MIN. MW, dBZ, hPa and `°` stay. Decimal comma in fixed numbers: « 28,2710° N », « M4,4 ». Money: « 12 k$ », « 3 M$ », « 2 Md$ ».
- "ago" goes before the time: « il y a {count} min ». When a placeholder already holds a localized "ago" text (`{age}`, `{ago}`), do not add « il y a » again.
- Round-about exit ordinals: 1re, 2e, 3e… (« prenez la 1re sortie »).
## Plurals
A `tn()` entry needs three forms. `Intl.PluralRules('fr')` picks:
| Form | Counts | Example |
| ------- | -------------------------------------- | -------------------- |
| `one` | 0 and 1, fractions below 2 (1,5) | 1 caméra, 0 caméra |
| `many` | 1 000 000, 2 000 000… (exact millions) | 1 000 000 de caméras |
| `other` | everything else: 2–999 999, 2,5… | 3 caméras |
The `many` form is the `other` text with « de » (« d' » before a vowel) after the number: « {count} de couches effacées », « {count} D'ENTRÉES INCONNUES ». When the words after the number cannot agree (an UPPERCASE readout, a list), put the number after a colon in every form: « CAPTÉS : {count} ». The same goes for a layer name whose gender and number vary: « Couche activée : {label} », not « {label} activé », or a tool name: « Outil actif : {name} », not « {name} est actif ».
## Glossary
| English | Français |
| ------------------------------------------------------------ | ----------------------------------------------------------------------- |
| layer / data layer / DATA LAYERS (panel title) | couche / couche de données / COUCHES |
| feed / source / data source | flux / source / source de données |
| LIVE (status) / live data | DIRECT / données en direct |
| SIMULATED | SIMULÉ |
| stale / degraded / unavailable | obsolète / dégradé / indisponible |
| retry / retrying (status: RETRYING X) | nouvel essai / réessayez (NOUVEL ESSAI : X) |
| rate-limited | limite de requêtes atteinte |
| key (API key) / POWER UP / POWERED UP | clé (clé API) / ALLUMER / ALLUMÉ |
| Provider Settings | paramètres des fournisseurs |
| enable / disable | activer / désactiver |
| globe / map / map source / basemap / map stack | globe / carte / source de carte / fond de carte / fond de carte |
| tile (map) | tuile |
| aircraft / flight / military flights | aéronef (avion in short copy) / vol / vols militaires |
| vessel, ship / tanker (AIS type) | navire / navire-citerne |
| satellite / shell (Starlink) / DENSE | satellite / couche de la constellation / DENSE |
| launch / launch site / space mission | lancement / site de lancement / mission spatiale |
| ascent / replay / payload / stage / downrange | ascension / rejeu / charge utile / étage / distance au sol (DIST. SOL) |
| contact / tracking / track (HUD) | contact / suivi / piste |
| brackets (HUD corner brackets around contacts) | cadres de ciblage |
| installation / site | installation / site |
| mapped / observed | cartographié / observé |
| camera / CCTV camera / ALPR camera | caméra / caméra CCTV / caméra LAPI |
| earthquake | séisme |
| active fire (FIRMS detection) / wildfire | feu actif / incendie |
| contained / containment (wildfire) | circonscrit / part circonscrite (not « maîtrisé », which is controlled) |
| advisory (NHC/CPHC cyclone) | bulletin |
| forecast / lead hours / valid / issued | prévision / échéances / valide / émise |
| AQHI (Canada) | CQAS (cote air santé) |
| radio station / tune | station / régler |
| bikeshare / transit | vélos partagés / transports en commun |
| route (directions) / turn (maneuver) | itinéraire / manœuvre |
| scene / shot / scene recipe | scène / plan / recette de scène |
| bundle / data pack / anchor | archive / paquet de données / ancre |
| import / export (noun: file, scene, project) | importation / exportation (not « import » / « export ») |
| capture camera as anchor / set move start (scene editor) | ancre à la position de la caméra / vue actuelle comme départ |
| story beat | étape du récit |
| briefing / panel / dock | briefing / panneau / dock |
| cockpit | cockpit |
| preset / visual preset | préréglage / préréglage visuel |
| Scope (circular viewport mask) / keyhole | lunette / ouverture |
| detection overlay | surcouche de détection |
| Clean UI | vue épurée |
| Street Level (layer) / Street View (Google) | Niveau rue / Street View |
| Recent Imagery / box | imagerie récente / zone |
| Daily overview / overview (Recent Imagery VIIRS product) | vue quotidienne (one name for the product, not « vue d'ensemble ») |
| pin (Draw tool) / pin (Recent Imagery) | épingle / épingler |
| Cyber (HUD layout, effect) | Cyber |
| heading / course (ground track) / altitude / ground speed | cap / route / altitude / vitesse sol |
| HDG / BRG / CRS / FOV / DEST / SPD / ALT | unchanged |
| RANGE / PITCH / HGT (CCTV calibration chips) | PORTÉE / INCL. / HAUT. |
| PREV / PREVIOUS / NEXT (chips) | PRÉC. / PRÉC. / SUIV. |
| Previous / Next (button text) | Précédent / Suivant |
| L / R (relative bearing) | G / D |
| SIG / NEWS / LOCAL (briefing tabs) | SIG / ACTUS / LOCAL |
| compass points (N, NNE, …, W, NW) | N, NNE, …, O, NO |
| NOT AN ALL-CLEAR | PAS UN FEU VERT |
| AI agent | agent IA |
| share (a view or link) | partager |
| click / click to … | cliquez / cliquer pour … |
| map heading (compass, north-up control) | orientation (de la carte) |
| subject (cockpit / shared contact) | cible |
| cloud cover / CLOUD (briefing readout) | nébulosité / NÉBULOSITÉ |
| LEVEL (cockpit horizon guide) | HORIZON |
| CALIBRATED / CURATED / RAW PRIOR (CCTV pose) | CALIBRÉE / VÉRIFIÉE / ESTIM. INITIALE |
| DATASHEET / SPEC FOV (CCTV lens range) | FICHE TECHNIQUE / FOV CONSTRUCTEUR |
| PORT TENDER (AIS type) | SERVITUDE PORT. (navire de servitude portuaire) |
| ChatGPT sign-in (action) / opening sign-in / sign-in expired | connexion / page de connexion / la session a expiré |
| authored shot (scene) | plan scénarisé |
| report / record (USGS event card) / readout, reading | rapport / relevé (a measured value only) |
+108
View File
@@ -0,0 +1,108 @@
# Italian (`it`)
Catalog: `src/i18n/locales/it.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation source: abiyeenzo (#912).
## Style
- Standard Italian as in Italian software UIs. Address the user with informal "tu" imperatives: «Aggiungi», «Attiva», «Scegli», «Incolla», «Riprova». Tooltips that name an action use the same imperative: «Espandi il pannello», «Ripristina la camera».
- Sentence case, not Title Case: "Live Flights" → «Voli live», "Space Missions" → «Missioni spaziali». UPPERCASE English stays UPPERCASE: «LIVE», «NON DISPONIBILE».
- Quotes inside Italian text are straight double quotes: «Eliminare lo scatto "{shot}"?». Use the accented capitals (È, À) in UPPERCASE text: «VELOCITÀ», «CITTÀ».
- Errors read as a statement, not a past participle on its own: "Search failed" → «Ricerca non riuscita»; "Could not …" → «Impossibile …». A dash that joins a cause and a hint becomes a colon: «Chiave API rifiutata: controlla AISSTREAM_API_KEY». The same goes for a label and its hint in a tooltip: «Direzione (bussola °): clic per digitare».
- Short labels, chips and HUD readouts stay short. A standard abbreviation with a period beats a wrapped line: REGOLAZ., RILEV., INCL., ALT., VEL., STIM., PROIEZ., ESP.; PREC / SUCC for PREV / NEXT and PREVIOUS / NEXT.
- The HUD codes HDG, BRG, CRS, FOV and DEST stay in English, as in cockpit avionics. RANGE is «PORTATA». Words used in a sentence are translated: heading → «prua» (aircraft, ships) or «direzione» (camera, buses, trams, trains), course → «rotta».
- Keyboard keys stay in English: Space, Enter, Esc, Tab, Shift, Backspace, H, 1–9 («Tieni premuto Space per parlare», «Premi Enter per riprovare»).
- Decimal comma in numbers written in the text, magnitudes included: «28,2710° N», «M5,2». Numbers formatted by the code follow `Intl` and need nothing.
- Amounts: thousands, millions and billions are written out or abbreviated in Italian, not with K/M/B: «{amount} mila $», «{amount} mln $», «{amount} mld $».
- Units: km, m, km/h, m/s, kn (knots), ft (feet), h, min, s; in UPPERCASE text KM, KM/S, KT, FT, MIN, H. "m" is always metres, so minutes are «min» («5 min fa»). MW stays MW. `°` and `%` stay.
- "ago" goes after the time: «{count} min fa». When a placeholder already holds a localized "ago" text (`{age}`, `{ago}`), do not add «fa» again.
- A feature name used as a sentence subject gets a head noun so the verb agrees: «La modalità Missioni spaziali isola…», «Il pannello Impostazioni provider è disattivato…», «Il livello {layer} è attivo…», «Lo strumento {name} è attivo…». A short card title with a placeholder name puts the state after a colon: «{label}: attivato». The same goes for a placeholder list of names: «Attivazione recente: {layers}». A count in a plain `t()` entry (no plural forms) that can be 1 goes after a label, so nothing has to agree with it: «Record accettati: {accepted} su {raw}».
- English loanwords that Italian UIs use as they are stay in English: feed, live, cockpit, briefing, preset, overlay, layout, snapshot, tile, provider, timeout, payload, bloom, scope.
## Plurals
A `tn()` entry needs three forms. `Intl.PluralRules('it')` picks:
| Form | Counts | Example |
| ------- | -------------------------------------- | ----------------------- |
| `one` | 1 | 1 telecamera |
| `many` | 1 000 000, 2 000 000… (exact millions) | 1 000 000 di telecamere |
| `other` | everything else: 0, 2–999 999, 1,5… | 3 telecamere |
The `many` form is the `other` text with «di» after the number when a noun follows: «{count} di telecamere caricate», «{count} DI CHIAVI IN ATTESA». When no noun follows the number («{count} vicine», «{count} RICEVUTI»), `many` repeats `other`. Nouns that do not change in the plural keep one form for all three (`{count} file`, `{count} data center`), with «di» in `many`.
## Glossary
| English | Italiano |
| --------------------------------------------- | ------------------------------------------------------------ |
| layer / data layer | livello / livello dati |
| feed / source / data source | feed / fonte / fonte dati |
| LIVE (status) / live data / live (feed state) | LIVE / dati live / live |
| SIMULATED | SIMULATO |
| stale / degraded / unavailable | obsoleto / degradato / non disponibile |
| retry / retrying | riprova / nuovo tentativo |
| rate limited | limite di richieste (raggiunto) |
| key (API key) / POWER UP / POWERED UP | chiave / ACCENDI / ACCESO |
| Provider Settings | Impostazioni provider |
| ON / OFF (state chips) | ON / OFF |
| enable / disable | attivare / disattivare |
| globe / map / map source / map stack | globo / mappa / fonte mappa / stack mappa |
| basemap / box (Recent Imagery area) | mappa di base / riquadro |
| aircraft, plane / flight / military flights | aereo / volo / voli militari |
| vessel, ship | nave |
| satellite / shell (Starlink) / DENSE | satellite / shell / DENSO |
| SPARSE / BALANCED / DENSE (detection modes) | RADO / BILANCIATO / DENSO (agree with «overlay») |
| broadband (Starlink shell) | a banda larga |
| VISUAL (CelesTrak group) | VISIBILI |
| launch (rocket) / launch site / space mission | lancio / sito di lancio / missione spaziale |
| ascent / replay / payload / stage | salita / replay / payload / stadio |
| contact / tracking | contatto / tracciamento |
| installation / site | installazione / sito |
| mapped / observed | mappato / osservato |
| camera (view) / camera, CCTV camera | camera / telecamera, telecamera CCTV |
| earthquake / active fire / fire perimeter | terremoto / incendio attivo / perimetro dell'incendio |
| lightning strike / cloud (cover, %) | fulmine / nuvole («non singoli fulmini», «20% di nuvole») |
| incident (wildfire, Type N) / scene incident | evento (Evento di tipo N, Incendio senza nome) / evento |
| trigger record (USGS card) / OPEN SOURCE link | scheda / APRI LA FONTE |
| advisory (NHC/CPHC cyclone) / cone | bollettino / cono di incertezza |
| forecast lead hours (cyclone points) | scadenze di previsione in ore |
| AIR / SEA (radio category) / ground station | ARIA / MARE / stazione di terra |
| radio station / broadcaster | stazione radio / emittente |
| CH (radio channel readout) | CH (unchanged) |
| road / flow (traffic) | strada / flusso |
| transit / bike share | trasporto pubblico / bike sharing |
| last report (vehicle position) | ultima segnalazione |
| scene / shot / scene recipe | scena / scatto / ricetta della scena |
| anchor / data pack / bundle (scene) | ancora / pacchetto dati / pacchetto |
| story beat | tappa del racconto |
| briefing / panel / dock | briefing / pannello / dock |
| cockpit | cockpit |
| preset / visual preset | preset / preset visivo |
| Tactical / Operator / Minimal / Cyber | Tattico / Operatore / Minimale / Cyber |
| Scope (circular viewport mask) / keyhole | scope / foro |
| feather / fade | sfumatura / dissolvenza |
| detection overlay | overlay di rilevamento |
| pin (Draw tool) / mark | segnaposto / segno |
| pin (Recent Imagery) | fissare |
| Draw / Directions (tools) | Disegno / Indicazioni |
| Street Level (layer) / Street View (Google) | Vista stradale / Street View |
| Recent Imagery | Immagini recenti |
| Global Context / Context (panel) | Contesto globale / Contesto |
| heading, course / altitude / ground speed | prua, rotta / altitudine / velocità al suolo (VEL. AL SUOLO) |
| altitude in short HUD captions / Alt unknown | QUOTA (QUOTA · FT) / Quota sconosciuta |
| fallback (FALLBACK, Street View fallback) | riserva / di riserva (SUPERFICIE DI RISERVA) |
| forecast track / advisory center (cyclones) | traccia prevista / centro del ciclone da bollettino |
| witness source / authored (scene) | fonte testimoniale / predefinito, definito dall'autore |
| explore manually | esplora manualmente |
| HDG / BRG / CRS / FOV / DEST | HDG / BRG / CRS / FOV / DEST (unchanged) |
| RANGE / PITCH / HGT (CCTV calibration) | PORTATA / INCL. / ALT. |
| PREV / NEXT | PREC / SUCC |
| Previous / Next (button text) | Precedente / Successivo |
| Focus {target} (action, aria label) | Inquadra {target} (the FOCUS button stays FOCUS) |
| L / R (relative bearing) | S / D |
| SIG / NEWS / LOCAL (briefing tabs) | SIG / NOTIZIE / LOCALE |
| compass points (N, NNE, …, W, NW) | N, NNE, …, O, NO (west is «O», ovest) |
| NOT AN ALL-CLEAR | NON È UN VIA LIBERA |
| weather field / model / valid / issued | campo / modello / valida / emessa |
| click / click to … | fai clic / clic per … |
+107
View File
@@ -0,0 +1,107 @@
# Brazilian Portuguese (`pt`)
Catalog: `src/i18n/locales/pt.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation source: abiyeenzo (#909).
## Style
- Brazilian Portuguese (pt-BR): câmera, tela, compartilhar, arquivo, baixar, ônibus, metrô. Every `pt` tag, `pt-PT` included, gets this catalog, so avoid slang and regionalisms.
- Address the user with «você» imperatives: «Clique no mapa», «Ative o CCTV», «Tente novamente». Tooltips that name an action use the infinitive: «Expandir painel», «Redefinir a câmera».
- Sentence case, not Title Case: "Live Flights" → «Voos ao vivo». UPPERCASE English stays UPPERCASE: «AO VIVO», «SEM DADOS».
- Quotes inside Portuguese text are straight double quotes: «Excluir o plano "{shot}"?». A sentence that names a panel quotes it: «o painel "Configurações de provedores"» as the subject, so the verb agrees, and «adicione em "Configurações de provedores"» after a preposition.
- A dash between clauses is a spaced em dash « — », even where the English runs it in.
- A date or time after a participle takes a preposition: «emitido em {time}», «válida para {valid}», «Posição em {time}», «onde estava em {time}». Use «em», not «às», wherever {time} can include a date.
- A count in a string with no plural form (no `tn()`) goes after a colon, so it reads right for 1: «sincronizando feeds de cidades: {count}...», «CÂMERAS EM ESPERA: {count}».
- A placeholder that holds a layer or tool name of unknown gender and number gets a noun to agree with: «{label}: camada ativada», «A ferramenta "{owner}" está usando o ponteiro — feche-a primeiro».
- Short labels, chips and HUD readouts stay short. A standard abbreviation with a period beats a wrapped line: ANT., PRÓX., DESLIG., EXCL., INCL., ALT., VEL., câm.
- The HUD codes HDG, BRG, CRS, FOV, DEST, SPD, RANGE, ALT, GS, TRK and V/S stay in English, as in cockpit avionics («ALT {altitude} FT · GS {speed} KT · TRK {track}», «V/S {rate} FT/MIN»), and so does the HUD recording mark REC. Words used in a sentence are translated: heading → «rumo», range → «alcance».
- Keyboard keys stay in English: Space, Esc, Enter, Tab, Shift, Backspace, H, 1–9 («Segure Space para falar»).
- Common English tech words used in Brazil stay: feed, stream, snapshot, briefing, dock, layout, bloom, status, app, link, token.
- Units: km, m, km/h, m/s, kt (knots), ft (feet), h, min, s, always spaced from the number («Terremotos (24 h)», «Missões espaciais (30 d)»; in the layer names the space is a no-break space, U+00A0, so the unit never wraps away from the number); in UPPERCASE text KM, KM/S, KT, FT, MIN, H. "m" is always metres, so minutes are «min» («há 5 min»). MW stays MW. `°` and `%` stay. Decimal comma in written numbers («M4,4»).
- "ago" goes before the time: «há {count} min», «mais recente há {value}». When a placeholder already holds a localized "ago" text (`{age}`, `{ago}`), do not add «há» again.
- Money: «US$ {amount} mil / mi / bi» for thousands, millions and billions.
- Compass points use Leste/Oeste: N, NNE, NE, ENE, L, ESE, SE, SSE, S, SSO, SO, OSO, O, ONO, NO, NNO; hemisphere letters N, S, L, O.
## Plurals
A `tn()` entry needs three forms. `Intl.PluralRules('pt')` follows Brazilian usage:
| Form | Counts | Example |
| ------- | -------------------------------------- | ------------------ |
| `one` | 0, 1 and anything below 2: 0, 1, 1.5 | 0 câmera, 1 câmera |
| `many` | 1 000 000, 2 000 000… (exact millions) | 1000000 de câmeras |
| `other` | everything else: 2–999 999, 2.5… | 3 câmeras |
The `many` form is the `other` text with «de» after the number: «{count} de câmeras carregadas», «{count} DE CHAVES PENDENTES». A `one` form is also read for 0, so keep it natural with zero («0 arquivo selecionado» is standard Brazilian usage).
## Glossary
| English | Português |
| --------------------------------------------- | ------------------------------------------------------------ |
| layer / data layer | camada / camada de dados |
| feed / source / data source | feed / fonte / fonte de dados |
| LIVE (status) / live data | AO VIVO / dados ao vivo |
| SIMULATED | SIMULADO |
| stale / degraded / unavailable | desatualizado / degradado / indisponível |
| retry / retrying | nova tentativa / tentando de novo |
| retrying (a fetch) / retrying slowly | buscando de novo / tentando de novo em intervalos maiores |
| rate-limited | «X: limite de requisições atingido» |
| dismiss (window, card, error) | fechar |
| key (API key) / POWER UP / POWERED UP | chave / ATIVAR / ATIVADO |
| Provider Settings | Configurações de provedores |
| ON / OFF (state chips) | ATIVO / INATIVO |
| enable / disable | ativar / desativar |
| globe / map / map source / map stack | globo / mapa / fonte do mapa / pilha de mapas |
| basemap / tile | mapa base / bloco |
| aircraft, plane / flight / military flights | aeronave, avião / voo / voos militares |
| vessel, ship / TANKER (AIS class) | embarcação / NAVIO-TANQUE |
| satellite / shell (Starlink) / DENSE | satélite / camada orbital / DENSO |
| SPARSE / BALANCED / DENSE (detection modes) | ESPARSO / EQUILIBRADO / DENSO (o modo) |
| launch (rocket) / launch site / space mission | lançamento / local de lançamento / missão espacial |
| KM DOWNRANGE / EST. DOWNRANGE | KM DO LOCAL DE LANÇAMENTO / EST. PELA DIST. DO LANÇAMENTO |
| ascent / replay / payload / stage | subida / reprise / carga útil / estágio |
| contact / tracking / track | contato / rastreamento / rastro |
| installation / site | instalação / local |
| PLACES CANDIDATE (installation class) | CANDIDATO DO GOOGLE PLACES |
| mapped / observed | mapeado / observado |
| camera / CCTV camera | câmera / câmera de CCTV |
| CALIBRATED / CURATED (CCTV badge) | CALIBRADA / COM CURADORIA |
| AUTO HOP ON / OFF (CCTV) | TROCA AUTO. LIGADA / DESLIG. (as the other CCTV toggles) |
| earthquake / active fire | terremoto / incêndio ativo |
| advisory (NHC/CPHC cyclone) | boletim |
| E/C Pacific (NHC/CPHC coverage) | Pacífico Leste/Central (no «L/C») |
| weather (layer, card) / weather satellite | clima / meteorologia, meteorológico |
| FOG / cloud unknown | NEVOEIRO / nebulosidade desconhecida |
| AQHI bands Low … Very High | Risco baixo … Risco muito alto |
| radio station / broadcaster | estação de rádio / emissora |
| Oldies / World (radio tags) | Flashback / Músicas do mundo |
| radio genre tags agree with «música» | Alternativa, Clássica, Eletrônica, Latina |
| road / flow (traffic) | via / fluxo |
| Street Level (layer) | Nível da rua |
| scene / shot / scene recipe | cena / plano / receita de cena |
| briefing / panel / dock | briefing / painel / dock |
| cockpit | cabine |
| preset / visual preset / EXPORT PRESETS | predefinição / predefinição visual / EXPORTAR PREDEF. |
| VISUAL PRESETS (dock caption) | PREDEF. VISUAIS |
| Scope (circular viewport mask) / keyhole | mira / visor |
| feather / fade | suavização / esmaecimento |
| detection overlay | sobreposição de detecção |
| pin (Draw tool) / box (Recent Imagery) | marcador / área |
| Cyber (HUD layout, effect) | Ciber |
| heading / course / altitude / ground speed | rumo / curso / altitude / velocidade solo (VELOC. SOLO) |
| map heading / bearing (compass, north up) | orientação (do mapa) |
| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | HDG / BRG / CRS / FOV / DEST / SPD / RANGE (unchanged) |
| V/S (vertical speed code) | V/S (unchanged) |
| PREV / NEXT | ANT. / PRÓX. |
| Previous / Next (button text) | Anterior / Próximo (Próxima) |
| L / R (relative bearing) | E / D |
| SIG / NEWS / LOCAL (briefing tabs) | SIG / NOTÍCIAS / LOCAL |
| REGIONAL NEWS (fits the 16-character kicker) | NOTÍCIAS DA ÁREA |
| PITCH / HGT (CCTV calibration chips) | INCL. / ALT. |
| NOT AN ALL-CLEAR | NÃO GARANTE SEGURANÇA |
| Last report (transit vehicle position) | Último registro |
| intersection (CCTV) | cruzamento |
| {layers} just turned on (any list of layers) | «{layers}: ativação recente» (no agreement needed) |
| {label} on / off (one layer) | «{label}: camada ativada / desativada» |
| turn-by-turn: turn left / keep left / lane | vire à esquerda / mantenha-se à esquerda / faixa da esquerda |
+106
View File
@@ -0,0 +1,106 @@
# Ukrainian (`uk`)
Catalog: `src/i18n/locales/uk.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation source: meirm (#312).
## Style
- Address the user with polite imperatives: «Натисніть», «Увімкніть», «Спробуйте ще раз».
- Sentence case, not Title Case: "Live Flights" → «Рейси онлайн».
- Ukrainian norms, not Russian calques: «у радіусі», «за посиланням», «надто довгий», «потім», «вигляд» (a camera view: «вигляд згори»), «стеження» (tracking); prepositions alternate у/в and і/й by euphony.
- The apostrophe is always ʼ (U+02BC): обʼєкт, звʼязок, памʼять. Not ' or ’.
- Quotes inside Ukrainian text are «ялинки»; a button or layer name inside a sentence goes in them: Увімкніть «Вуличні знімки».
- In short labels, a standard abbreviation with a period beats a wrapped line: ПОПЕР., ДАЛЬН., КАЛІБР.
- Units: км, м, км/год, м/с, вуз (knots), фт (feet), год, хв, с, МГц, дБ, гПа; in UPPERCASE text КМ, М, КМ/С, ВУЗ, ФТ. MW is МВт in any case. `°` and `%` stay. Decimal comma in coordinates written as text: 28,2710° пн. ш.
- Coordinates: пн. ш. / пд. ш. / сх. д. / зх. д.; the HUD hemisphere letters are Пн, Пд, Сх, Зх.
## Plurals
A `tn()` entry needs four forms. `Intl.PluralRules('uk')` picks:
| Form | Counts | Example |
| ------- | ------------------------ | ---------- |
| `one` | 1, 21, 31, 101… | 1 камера |
| `few` | 2–4, 22–24, 32–34… | 3 камери |
| `many` | 0, 5–20, 25–30, 111–114… | 5 камер |
| `other` | fractions: 1.5, 2.25… | 1,5 камери |
`other` takes the genitive singular: 1,5 кадру, 1,5 літака, 1,5 ключа. When a word next to a number cannot agree in every form, move the number after a colon instead: «поруч: {count}», «відповідей: {responses}». The same trick keeps place names and tool names out of a preposition's case: «звідки: {origin}», «Активно: {name}».
## Glossary
| English | Українська |
| ----------------------------------------------- | ---------------------------------------------------------------- |
| layer / data layer | шар / шар даних |
| feed | потік даних (short: потік) |
| source / data source | джерело / джерело даних |
| LIVE (status) / live data | ОНЛАЙН / у реальному часі (status chip: ДАНІ ОНЛАЙН) |
| SIMULATED / SYNTHETIC | ІМІТАЦІЯ |
| stale / degraded / unavailable | застарів / з перебоями (chip: ПЕРЕБОЇ) / недоступний |
| retry | повтор |
| key (API key) / POWER UP | ключ / ЗАПУСК |
| Provider Settings / provider | Налаштування постачальників / постачальник |
| globe / map / map source / basemap | глобус / карта / джерело карти / підкладка |
| aircraft / flight / military flights | літак (short HUD: борт) / рейс / військові рейси |
| vessel, ship / vessels | судно / судна (gen. pl. суден) |
| satellite | супутник |
| launch (rocket) / space mission | запуск / космічна місія |
| ascent (rocket) / booster FLIGHT {n} | виведення / ПОЛІТ {n} (not рейс) |
| contact / tracking | контакт / стеження |
| installation, site / mapped | обʼєкт / нанесений (обʼєкт, контакт) |
| camera / CCTV camera | камера / камера CCTV |
| earthquake / active fire / fire perimeter | землетрус / активна пожежа / контур пожежі |
| advisory (NHC/CPHC cyclone) | зведення |
| scene / shot / scene recipe | сцена / кадр / рецепт сцени |
| briefing / panel / dock | брифінг / панель / док |
| preset / visual preset | пресет / візуальний пресет |
| Scope, keyhole (circular viewport mask) | приціл |
| overlay (detection overlay) / world overlay | накладка / накладка на глобусі |
| heading / altitude / ground speed | курс / висота / шляхова швидкість |
| map bearing, map heading | азимут карти |
| HDG / BRG / CRS, TRK / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ШК (шляховий кут) / ОГЛЯД / ПРИЗН. / ШВИД / ДАЛЬН. |
| Street View (Google) | Перегляд вулиць (as in Ukrainian Google Maps) |
| Street Level (layer) | Вуличні знімки |
| imagery / recent imagery | знімки / свіжі знімки |
| radar reflectivity / lightning density | радіолокаційна відбивність / щільність блискавок |
| infrared | інфрачервоний (short: ІЧ) |
| receiver / gain / decoder | приймач / підсилення / декодер |
| broadcaster | мовник |
| CONUS, WHOT/BHOT, Ironbow, CRT, NVG, FLIR | без перекладу |
| PREV / NEXT (HUD) / Previous / Next | ПОПЕР. / ДАЛІ / Попередній / Наступний |
| anchor / data pack / bundle (scene) | якір / пакет даних / архів |
| box (Recent Imagery area) | область |
| camera move | переліт |
| SIG / NEWS / LOCAL (briefing tabs) | СИГН. / НОВИНИ / МІСЦ. |
| compass points (N, NNE, NE, …) | Пн, ПнПнСх, ПнСх, … |
| L / R (relative bearing) | Л / П |
| click / tap | клацнути / натиснути |
| session (voice, OAuth) | сеанс |
| sequence (street-level photos) | послідовність |
| search (place search) | пошук місця |
| pitch (CCTV camera pose) | нахил |
| traffic (road traffic layer) | дорожній рух |
| phosphor (CRT) | люмінофор |
| view (map extent) / view centre / in view | видима область (карти) / центр карти / у кадрі |
| select (verb) / selected | вибрати / вибраний (not обрати) |
| ping (cockpit proximity contacts) | контакт |
| merge (turn-by-turn) / on ramp | влитися в потік / вʼїзд (off ramp: зʼїзд) |
| watch (Earth/Orbital Watch) | спостереження |
| timeout | тайм-аут |
| icon | значок (not іконка) |
| run (scene playback) / RUN LOG | відтворення / ЖУРНАЛ ВІДТВОРЕННЯ (запуск is a rocket launch) |
| Preview (voice panel replies) | відповіді |
| Marine (radio tag) / pleasure craft | море (not флот) / прогулянкове судно |
| emergency (ADS-B) / Emergency (radio tag) | аварійний стан / надзвичайні ситуації |
| confidence (FIRMS fire detection) | достовірність (short: достов.) |
| thermal (FLIR-style imaging) | тепловий (not термальний) |
| map stack | набір карт |
| stage (rocket) re-entry / recovery | вхід ступеня в атмосферу / повернення |
| ON / OFF (toggle chips) | УВІМК. / ВИМК. |
| Clean UI / EXIT CLEAN VIEW | чистий інтерфейс / ПОВЕРНУТИ ІНТЕРФЕЙС |
| full globe view | вигляд всього глобуса |
| inputs (cockpit context chip) | потоки (not вхідні дані) |
| downrange (rocket stage) | дальність |
| resolution (spatial) | роздільна здатність (not роздільність) |
| shared follow expired | посилання на стеження застаріло |
-1
View File
@@ -212,7 +212,6 @@
"src/styles/thermal.js",
"src/styles/anime.js",
"src/styles/noir.js",
"src/styles/snow.js",
"scripts/qa-visual-effects.mjs",
"scripts/qa-draw-tool.mjs",
"scripts/qa-geo-import.mjs",
+5 -2
View File
@@ -125,8 +125,11 @@ export function extractMessages() {
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);
if (/\sdata-i18n(\s|=|$)/.test(attrs)) {
const context = attrs.match(/\sdata-i18n-context="([^"]*)"/);
const message = normalize(decodeHtml(text));
add(context ? `${decodeHtml(context[1])}|${message}` : message, where);
}
for (const [, name] of attrs.matchAll(
/\sdata-i18n-(title|aria-label|placeholder)\b/g,
)) {
+229 -12
View File
@@ -439,9 +439,14 @@
"src/ui/slider.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -464,7 +469,6 @@
"src/styles/thermal.js",
"src/styles/anime.js",
"src/styles/noir.js",
"src/styles/snow.js",
"src/i18n/marker.js"
],
"external": ["cesium"]
@@ -480,9 +484,14 @@
"src/localRequestGate.mjs",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -511,9 +520,14 @@
"src/data/layerSnapshot.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js",
@@ -534,9 +548,14 @@
"src/app/stateChannel.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -552,9 +571,14 @@
"src/ui/radioTunerModel.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -573,9 +597,14 @@
"src/ui/cctvPresentation.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js",
@@ -601,9 +630,14 @@
"src/ui/panelRailGeometry.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/layerNames.js"
@@ -637,9 +671,14 @@
"src/ui/panelRailGeometry.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js"
@@ -655,9 +694,14 @@
"src/ui/actionButton.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js"
@@ -706,7 +750,6 @@
"src/styles/anime.js",
"src/styles/noir.js",
"src/styles/retro.js",
"src/styles/snow.js",
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/ui/applicationShell.js",
@@ -815,9 +858,14 @@
"src/data/cameraArrival.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js",
@@ -825,7 +873,8 @@
"src/ui/layerNames.js",
"src/ui/statusText.js",
"src/ui/statusBadge.js",
"src/scopeMaskPolicy.js"
"src/scopeMaskPolicy.js",
"src/visualStylePolicy.js"
],
"external": ["cesium"]
},
@@ -938,12 +987,18 @@
"src/data/cameraArrival.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js"
"src/ui/unitLabels.js",
"src/visualStylePolicy.js"
],
"external": ["cesium"]
},
@@ -1007,9 +1062,14 @@
"src/data/modelEligibility.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js"
@@ -1057,9 +1117,14 @@
"src/data/modelEligibility.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -1121,9 +1186,14 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -1152,9 +1222,14 @@
"src/maps/imageryHost.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js",
@@ -1184,9 +1259,14 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -1216,9 +1296,14 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js"
@@ -1251,9 +1336,14 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -1295,9 +1385,14 @@
"src/data/infrastructureOverlayEntry.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/layers/installations/classLabel.js"
@@ -1333,9 +1428,14 @@
"src/ui/panelRailGeometry.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js"
@@ -1385,9 +1485,14 @@
"src/layers/traffic/roadModes.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -1416,9 +1521,14 @@
"src/data/cameraSensitivity.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -1466,9 +1576,14 @@
"src/layers/cctv/sourcePolicy.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -1531,9 +1646,14 @@
"scripts/qa-street-level.mjs",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/layers/streetLevel/providers/mapillary/policy.js"
@@ -1562,9 +1682,14 @@
"src/layers/alpr/floors.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -1604,9 +1729,14 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -1628,9 +1758,14 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -1686,9 +1821,14 @@
"src/maps/imageryHost.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -1750,9 +1890,14 @@
"src/voice/pointerReticle.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js",
@@ -2269,7 +2414,6 @@
"src/styles/anime.js",
"src/styles/noir.js",
"src/styles/retro.js",
"src/styles/snow.js",
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/ui/applicationShell.js",
@@ -2635,9 +2779,14 @@
"src/app/embed.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js",
@@ -2660,7 +2809,8 @@
"src/voice/geminiCaptureWorklet.js",
"src/ui/statusBadge.js",
"src/sceneCommandRelease.js",
"src/scopeMaskPolicy.js"
"src/scopeMaskPolicy.js",
"src/visualStylePolicy.js"
],
"external": [
"hls.js",
@@ -3221,9 +3371,14 @@
"src/sources/infrastructureData.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js",
@@ -3333,9 +3488,14 @@
"src/app/stateChannel.js",
"src/data/cameraArrival.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/i18n/index.js"
@@ -3358,9 +3518,14 @@
"src/ui/uiLifetime.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -3380,7 +3545,6 @@
"src/styles/anime.js",
"src/styles/noir.js",
"src/styles/retro.js",
"src/styles/snow.js",
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/ui/applicationShortcuts.js",
@@ -3396,13 +3560,19 @@
"src/i18n/marker.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/scopeMaskPolicy.js",
"src/ui/displayControls.js"
"src/ui/displayControls.js",
"src/visualStylePolicy.js"
],
"external": ["cesium"]
},
@@ -3424,9 +3594,14 @@
"src/ui/uiLifetime.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -3517,9 +3692,14 @@
"src/data/routeSteps.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js"
@@ -3574,9 +3754,14 @@
"src/voice/pointerReticle.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js",
@@ -3830,9 +4015,14 @@
"src/sources/capability.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -3875,9 +4065,14 @@
"src/sources/transitHistory.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -3930,9 +4125,14 @@
"src/layers/perimeters/inciweb.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
@@ -3948,9 +4148,14 @@
"src/layers/gnss/records.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js",
@@ -3978,7 +4183,6 @@
"src/styles/anime.js",
"src/styles/noir.js",
"src/styles/retro.js",
"src/styles/snow.js",
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/view/index.js",
@@ -3987,7 +4191,8 @@
"src/data/idSwitches.js",
"src/data/detectionPolicy.js",
"src/scopeMaskPolicy.js",
"src/i18n/marker.js"
"src/i18n/marker.js",
"src/visualStylePolicy.js"
],
"external": []
},
@@ -4009,9 +4214,14 @@
"src/i18n/marker.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/data/geoImportImages.js",
@@ -4092,7 +4302,8 @@
"src/tools/views.js",
"src/tools/globePanel.js",
"src/tools/queries/panelRequest.js",
"src/geoDistance.js"
"src/geoDistance.js",
"src/visualStylePolicy.js"
],
"external": ["satellite.js"]
},
@@ -4193,7 +4404,8 @@
"src/view/index.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json"
"src/data/layerStateTokenReservations.json",
"src/visualStylePolicy.js"
],
"external": []
},
@@ -4207,9 +4419,14 @@
"src/i18n/marker.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js",
+17 -17
View File
@@ -1603,7 +1603,7 @@ try {
cockpit.setVisionMode('optical');
current.focus({ preventScroll: true });
const states = [read()];
for (let index = 0; index < 7; index += 1) {
for (let index = 0; index < 6; index += 1) {
current.click();
states.push(read());
}
@@ -1637,36 +1637,36 @@ try {
reentered,
};
});
const cycleModes = ['optical', 'crt', 'nvg', 'thermal', 'anime', 'noir', 'snow', 'optical'];
const cycleLabels = ['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR', 'SNOW', 'NORMAL'];
const cycleModes = ['optical', 'crt', 'nvg', 'thermal', 'anime', 'noir', 'optical'];
const cycleLabels = ['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR', 'NORMAL'];
check(
'Cockpit vision control exposes Normal, CRT, NVG, FLIR, Anime, Noir and Snow before wrapping',
'Cockpit vision control exposes Normal, CRT, NVG, FLIR, Anime and Noir before wrapping',
JSON.stringify(visionCycle.states.map((state) => state.mode)) === JSON.stringify(cycleModes)
&& JSON.stringify(visionCycle.states.map((state) => state.label)) === JSON.stringify(cycleLabels)
&& visionCycle.states[5]?.aria === 'Current cockpit vision style: Noir. Activate for next style.'
&& visionCycle.states[7]?.aria === 'Current cockpit vision style: Normal. Activate for next style.',
&& visionCycle.states[6]?.aria === 'Current cockpit vision style: Normal. Activate for next style.',
JSON.stringify(visionCycle.states.map(({ mode, label, aria }) => ({ mode, label, aria }))),
);
check(
'user vision changes open Display for every temporary treatment and retain selector focus',
visionCycle.states.slice(1, 7).every((state) => state.displayOpen === 'true')
&& visionCycle.states.slice(1, 7).every((state) => state.radioOpen === 'false')
&& visionCycle.states.slice(1, 7).every((state) => state.parametersActive)
&& visionCycle.states.slice(1, 7).every((state) => !state.parametersCollapsed)
&& visionCycle.states.slice(1, 7).every((state) => state.signalCollapsed)
visionCycle.states.slice(1, 6).every((state) => state.displayOpen === 'true')
&& visionCycle.states.slice(1, 6).every((state) => state.radioOpen === 'false')
&& visionCycle.states.slice(1, 6).every((state) => state.parametersActive)
&& visionCycle.states.slice(1, 6).every((state) => !state.parametersCollapsed)
&& visionCycle.states.slice(1, 6).every((state) => state.signalCollapsed)
&& visionCycle.states.every((state) => state.focusRetained),
JSON.stringify(visionCycle.states),
);
check(
'each Cockpit vision mode drives only its own shader and Normal clears them all',
visionCycle.states.slice(1, 7).every((state) => {
visionCycle.states.slice(1, 6).every((state) => {
const target = {
crt: 'retro', nvg: 'surveillance', thermal: 'thermal', anime: 'anime', noir: 'noir', snow: 'snow',
crt: 'retro', nvg: 'surveillance', thermal: 'thermal', anime: 'anime', noir: 'noir',
}[state.mode];
return Object.entries(state.intensities).every(([name, value]) =>
name === target ? value > 0 : value === 0);
})
&& Object.values(visionCycle.states[7].intensities).every((value) => value === 0),
&& Object.values(visionCycle.states[6].intensities).every((value) => value === 0),
JSON.stringify(visionCycle.states.map(({ mode, intensities }) => ({ mode, intensities }))),
);
check(
@@ -2317,13 +2317,13 @@ try {
const styleManager = window.__godsEyeView.styleManager;
const styleBefore = styleManager.activeStyle;
// A Cockpit-only choice that Reset must undo.
styleManager.cockpitView.setVisionMode('snow');
styleManager.cockpitView.setVisionMode('anime');
window.__qaCockpitReset = {
calls: 0,
original: manager.resetToGlobeView,
subjectId: awareness?.getContextSnapshot?.()?.subject?.id || null,
styleBefore,
snowDuringCockpit: styleManager.stages?.snow?.uniforms?.intensity,
animeDuringCockpit: styleManager.stages?.anime?.uniforms?.intensity,
};
manager.resetToGlobeView = function qaCountedCockpitReset(...args) {
window.__qaCockpitReset.calls += 1;
@@ -2360,7 +2360,7 @@ try {
height,
subjectPreserved: Boolean(qa.subjectId && subjectId === qa.subjectId),
styleRestored: gev.styleManager.activeStyle === qa.styleBefore,
snowDuringCockpit: qa.snowDuringCockpit,
animeDuringCockpit: qa.animeDuringCockpit,
// Cockpit-time intensities are Cockpit's own, so check against the map preset.
visionRestored: Object.entries(gev.styleManager.stages || {}).every(([name, stage]) =>
name === gev.styleManager.activeStyle
@@ -2376,7 +2376,7 @@ try {
&& resetState.resetHidden
&& Math.abs(resetState.height - 18_000_000) < 150_000
&& resetState.subjectPreserved
&& resetState.snowDuringCockpit > 0
&& resetState.animeDuringCockpit > 0
&& resetState.styleRestored
&& resetState.visionRestored,
JSON.stringify(resetState),
+8 -8
View File
@@ -200,8 +200,8 @@ const checkResponsiveStyleFocus = async (width) => {
await page.focus('#control-panel-toggle');
await pressTabs(2); // disclosure -> pin -> Normal
}
for (const [index, style] of ['normal', 'thermal', 'snow'].entries()) {
if (index) await pressTabs(3);
for (const [tabs, style] of [[0, 'normal'], [3, 'thermal'], [2, 'noir']]) {
if (tabs) await pressTabs(tabs);
const state = await styleFocusMetrics();
check(`${width} px ${style} keyboard ring is visible and unclipped`,
hasVisibleStyleFocus(state, style) && state.selectedStyle === 'normal'
@@ -567,13 +567,13 @@ try {
// Visual Styles precede the five Map Source tiles. Keep the existing opening
// handoff to selected OSM, then use the user's real Shift+Tab path into styles.
await pressTabs(5, true);
const snowFocus = await styleFocusMetrics();
check('Shift+Tab reaches Snow with a visible ring while Normal and OSM stay selected',
hasVisibleStyleFocus(snowFocus, 'snow') && !snowFocus.selected
&& snowFocus.selectedStyle === 'normal' && snowFocus.selectedMap === 'osm',
JSON.stringify(snowFocus));
const noirFocus = await styleFocusMetrics();
check('Shift+Tab reaches Noir with a visible ring while Normal and OSM stay selected',
hasVisibleStyleFocus(noirFocus, 'noir') && !noirFocus.selected
&& noirFocus.selectedStyle === 'normal' && noirFocus.selectedMap === 'osm',
JSON.stringify(noirFocus));
await page.screenshot({ path: path.join(shotsDir, 'keyboard-style-focus.png') });
await pressTabs(3, true);
await pressTabs(2, true);
const middleFocus = await styleFocusMetrics();
check('Shift+Tab traverses the middle FLIR style without activating it',
hasVisibleStyleFocus(middleFocus, 'thermal') && !middleFocus.selected
-1
View File
@@ -80,7 +80,6 @@ try {
'thermal',
'anime',
'noir',
'snow',
'normal',
]) {
await page.evaluate(
+1 -1
View File
@@ -46,7 +46,7 @@ function realtimeInstructions(
// string is the whole rollback.
'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.',
'For visual filter requests, call set_visual_style with one of the allowed style IDs.',
'SHIPS vs STYLES: "vessels" is easily heard as "visuals". "Show me vessels", "turn on vessels", "the vessels", "visuals" or "vestibules" said WITHOUT a style name means the ships layer: set_layer_visibility{layerId:"ais-live-vessels"}, exactly as "ships" or "boats" does. Call set_visual_style only when the user names a style (retro/CRT, surveillance/night vision, thermal, anime, noir, snow, normal) or says filter, effect or look. Never turn on a different data layer (such as fires) for a ships request.',
'SHIPS vs STYLES: "vessels" is easily heard as "visuals". "Show me vessels", "turn on vessels", "the vessels", "visuals" or "vestibules" said WITHOUT a style name means the ships layer: set_layer_visibility{layerId:"ais-live-vessels"}, exactly as "ships" or "boats" does. Call set_visual_style only when the user names a style (retro/CRT, surveillance/night vision, thermal, anime, noir, normal) or says filter, effect or look. Never turn on a different data layer (such as fires) for a ships request.',
'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any other mention of "satellite" or "satellites" means the satellites DATA LAYER via set_layer_visibility, never a basemap — except "recent/latest satellite imagery" (the recent-imagery layer) and "weather satellite"/clouds (weather-satellite). "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.',
'HUD requests ("hud on/off", "switch to operator/minimal/tactical/cyber layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
'Cyber sonar requests use set_cyber_sonar: enabled, rings, rangePct, intensityPct (Power), opacityPct, sectorDeg. For settings questions read get_current_view_state.controls.hud.sonar. Sonar is not detection. Do not silently switch themes, reveal the HUD or enable sonar when changing a slider. If the user explicitly requests Cyber plus sonar settings, call set_hud first, wait for success, then set_cyber_sonar. Confirm returned values; mapSweepActive and contactSweepActive distinguish saved settings from active map effects, including in Cockpit or reduced motion. Do not claim visible contacts solely because enabled is true.',
+14
View File
@@ -146,6 +146,12 @@ test('an embedded page takes views only from its parent and answers it', async (
id: 3,
view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] },
});
// A view kept from before Snow was retired still applies, on Normal.
send(parent, {
type: 'gev:view',
id: 4,
view: { camera: { lat: 1, lon: 2 }, style: 'snow' },
});
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(posted[1], {
type: 'gev:view-applied',
@@ -161,6 +167,13 @@ test('an embedded page takes views only from its parent and answers it', async (
name === 'set_layer_visibility' && args.layerId === 'flights',
),
);
assert.equal(posted[3].id, 4);
assert.equal(posted[3].ok, true);
assert.ok(
calls.some(
([name, args]) => name === 'set_visual_style' && args.style === 'normal',
),
);
assert.equal(posted.filter((message) => message.id === 1).length, 0);
// Ready names no data and goes to any parent; answers go only to the
// origin that asked.
@@ -168,6 +181,7 @@ test('an embedded page takes views only from its parent and answers it', async (
'*',
'https://host.example',
'https://host.example',
'https://host.example',
]);
remove();
});
+1 -1
View File
@@ -87,7 +87,7 @@ test('circular angle distance remains small across the wrap point', () => {
test('celestial ring is available only in Normal style', () => {
assert.equal(isCelestialRingStyleSupported('normal'), true);
for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow']) {
for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir']) {
assert.equal(isCelestialRingStyleSupported(style), false);
}
});
+1 -1
View File
@@ -151,7 +151,7 @@ test('Cockpit vision cycle exposes every map preset exactly once', () => {
);
assert.match(
setVisionMode.toString(),
/const labels = \{\s*optical: t\('NORMAL'\),\s*crt: t\('CRT'\),\s*nvg: t\('NVG'\),\s*thermal: t\('FLIR'\),\s*anime: t\('ANIME'\),\s*noir: t\('NOIR'\),\s*snow: t\('SNOW'\),?\s*\};/,
/const labels = \{\s*optical: t\('NORMAL'\),\s*crt: t\('CRT'\),\s*nvg: t\('NVG'\),\s*thermal: t\('FLIR'\),\s*anime: t\('ANIME'\),\s*noir: t\('NOIR'\),?\s*\};/,
);
assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
assert.doesNotMatch(ui, /getInheritedVisionLabel/);
-3
View File
@@ -5,7 +5,6 @@ export const COCKPIT_VISION_MODES = Object.freeze([
'thermal',
'anime',
'noir',
'snow',
]);
const TARGET_STYLE_BY_MODE = Object.freeze({
@@ -14,7 +13,6 @@ const TARGET_STYLE_BY_MODE = Object.freeze({
thermal: 'thermal',
anime: 'anime',
noir: 'noir',
snow: 'snow',
});
const COCKPIT_MODE_BY_STYLE = Object.freeze({
@@ -24,7 +22,6 @@ const COCKPIT_MODE_BY_STYLE = Object.freeze({
thermal: 'thermal',
anime: 'anime',
noir: 'noir',
snow: 'snow',
});
/** Normalize a requested Cockpit vision mode to the unfiltered Normal entry. */
+1 -2
View File
@@ -23,7 +23,6 @@ test('Cockpit vision order mirrors the unique map preset order', () => {
'thermal',
'anime',
'noir',
'snow',
]);
assert.equal(normalizeCockpitVisionMode('none'), 'optical');
assert.equal(normalizeCockpitVisionMode('unknown'), 'optical');
@@ -36,7 +35,7 @@ test('Cockpit entry selects the vision mode equivalent to the active map style',
assert.equal(cockpitVisionModeForStyle('thermal'), 'thermal');
assert.equal(cockpitVisionModeForStyle('anime'), 'anime');
assert.equal(cockpitVisionModeForStyle('noir'), 'noir');
assert.equal(cockpitVisionModeForStyle('snow'), 'snow');
assert.equal(cockpitVisionModeForStyle('snow'), 'optical');
assert.equal(cockpitVisionModeForStyle('unsupported'), 'optical');
});
+47
View File
@@ -1719,3 +1719,50 @@ test('vessel selection passes the opaque source reference to optional history',
aisLiveVesselsLayer.setSource(createVesselSource());
}
});
test('vessel focus wire skips hidden sprites at rest while a target is tracked', () => {
const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) });
const sprite = (show) => ({
position: { x: 1, y: 2, z: 3 },
show,
color: makeColor(1),
});
const visible = sprite(true);
const hidden = Array.from({ length: 50 }, () => sprite(false));
const records = [visible, ...hidden].map((billboard) => ({
position: billboard.position,
billboard,
}));
const target = {
screenRect: { left: 40, top: 40, right: 60, bottom: 60 },
paddingPx: 0,
cameraDistance: 1000,
};
let projected = 0;
let measured = 0;
const apply = (nowMs) =>
applyVesselFocusDeemphasis({
records,
target,
nowMs,
screenPositionFor: () => {
projected += 1;
return { x: 50, y: 50 };
},
cameraDistanceFor: () => {
measured += 1;
return 1200;
},
params: { paddingPx: 0, dimFloor: 0.25, attackMs: 300 },
});
apply(0);
const result = apply(300);
// Only the on-screen sprite pays for projection and distance.
assert.equal(projected, 2);
assert.equal(measured, 2);
assert.equal(visible.color.alpha, 0.25);
assert.equal(result.activeCount, 1);
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
});
+16
View File
@@ -932,3 +932,19 @@ test('ramp and lane wording stays localized after choosing the maneuver form', (
);
}
});
test('the reroute tooltip keeps German mode nouns capitalized', (t) => {
t.after(() => setLocale('en'));
const title = (id) =>
directionsRowControls(idle).chips.find((chip) => chip.id === id).title;
assert.equal(title('mode-foot'), 'Walk — reroute for walk');
setLocale('de');
assert.equal(
title('mode-foot'),
'Zu Fuß – Route im Modus „Zu Fuß“ neu berechnen',
);
assert.equal(
title('mode-bike'),
'Fahrrad – Route im Modus „Fahrrad“ neu berechnen',
);
});
+15
View File
@@ -555,6 +555,21 @@ export function advanceFocusEvidenceNowMs(deltaMs) {
return _focusEvidenceNowMs;
}
/**
* True when a sprite holds no dimming state, or has settled at full emphasis
* with nothing pending. Advancing such a sprite without a screen position (a
* hidden sprite) changes neither its state nor its alpha, so a layer pass may
* skip it.
* @param {object} sprite
* @returns {boolean}
*/
export function spriteFocusAtRest(sprite) {
const state = _spriteStates.get(sprite);
return (
!state || (state.factor === 1 && state.desired === 1 && !state.wasFarther)
);
}
/** Forget state when a primitive is removed from its owning collection. */
export function forgetSpriteFocus(sprite) {
if (sprite) _spriteStates.delete(sprite);
+1 -1
View File
@@ -20,7 +20,7 @@
* jam keeps a size bump so queues read as fat beads.
* - `crt` (retro): saturated primaries that survive 10-level posterization
* plus an all-bucket size boost to out-shout the pixel grid.
* - `normal` (normal/anime/snow/unknown): every lookup returns null/0 —
* - `normal` (normal/anime/unknown): every lookup returns null/0 —
* the shipped palette and sizing apply untouched.
*
* Sim/uncovered dots (bucket null/'sim') are NEVER restyled under any
+1 -1
View File
@@ -13,7 +13,7 @@ const luma = ([r, g, b]) => (0.299 * r + 0.587 * g + 0.114 * b) / 255;
const BUCKETS = ['free', 'slow', 'jam'];
const MONO_STYLES = ['surveillance', 'thermal', 'noir'];
const NORMAL_STYLES = ['normal', 'anime', 'snow', 'does-not-exist', '', undefined, null];
const NORMAL_STYLES = ['normal', 'anime', 'does-not-exist', '', undefined, null];
test('profile mapping: NVG/FLIR/noir are mono, retro is crt, everything else normal', () => {
for (const s of MONO_STYLES) assert.equal(trafficStyleProfile(s), 'mono', s);
+1 -1
View File
@@ -18,7 +18,7 @@
* black sprite with a light ring is what a hot object looks like there.
* - `crt` (retro): saturated per-mode colours that survive posterisation,
* a size boost to out-shout the pixel grid, and a thinner halo.
* - `normal` (normal/anime/snow/unknown): every lookup returns the shipped
* - `normal` (normal/anime/unknown): every lookup returns the shipped
* value — mode colour, shipped size, hairline edge only.
*
* Brackets are a different matter. The detection canvas composites ABOVE the
-1
View File
@@ -17,7 +17,6 @@ test('profile mapping: NVG/FLIR/noir and cockpit nvg are mono, retro is crt, eve
for (const plain of [
'normal',
'anime',
'snow',
'minecraft',
'',
null,
+9
View File
@@ -135,6 +135,15 @@ test('document byte, nesting, collection, finite-number and string bounds are en
);
});
test('a shot saved with the retired Snow style migrates to Normal', () => {
const project = fixture();
project.scenes[0].shots[0].visual = { style: 'snow' };
const migrated = normalizeProject(
parseSceneDocument(JSON.stringify(project)),
);
assert.equal(migrated.scenes[0].shots[0].visual.style, 'normal');
});
test('captured scope and extended detection edits survive migration', () => {
const project = fixture();
project.scenes[0].shots[0].visual = {
+13
View File
@@ -1040,6 +1040,19 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
'rotate_globe',
'stop',
]);
// Snow is retired from set_visual_style; restore it to keep the legacy pin.
const visualStyle = legacyTools.find(
(tool) => tool.name === 'set_visual_style',
).parameters.properties.style;
assert.deepEqual(visualStyle.enum, [
'normal',
'retro',
'surveillance',
'thermal',
'anime',
'noir',
]);
visualStyle.enum = [...visualStyle.enum, 'snow'];
const block = JSON.stringify(legacyTools);
// Re-derived for the voice layer manifest (generated layer enums, alias and
// field hints), point-and-ask's pointer/referent arguments and the prompt
-1
View File
@@ -76,7 +76,6 @@ const MODE_LABELS = Object.freeze({
thermal: msg('FLIR'),
anime: msg('ANIME'),
noir: msg('NOIR'),
snow: msg('SNOW'),
});
/** Region ids from _regionLabel() and the name shown for each. */
+67 -2
View File
@@ -34,8 +34,20 @@ function memoryStorage(initial = {}) {
test('normalizeLocale folds regional variants and rejects unshipped tags', () => {
assert.equal(normalizeLocale('ru-RU'), 'ru');
assert.equal(normalizeLocale(' RU '), 'ru');
assert.equal(normalizeLocale('uk'), 'uk');
assert.equal(normalizeLocale('uk-UA'), 'uk');
assert.equal(normalizeLocale('en_GB'), 'en');
assert.equal(normalizeLocale('de'), 'de');
assert.equal(normalizeLocale('de-AT'), 'de');
assert.equal(normalizeLocale('de_CH'), 'de');
assert.equal(normalizeLocale('fr'), 'fr');
assert.equal(normalizeLocale('fr-CA'), 'fr');
assert.equal(normalizeLocale('FR_be'), 'fr');
assert.equal(normalizeLocale('pt-BR'), 'pt');
assert.equal(normalizeLocale('pt_PT'), 'pt');
assert.equal(normalizeLocale('xx'), null);
assert.equal(normalizeLocale('it-IT'), 'it');
assert.equal(normalizeLocale('it_CH'), 'it');
assert.equal(normalizeLocale('ja'), 'ja');
assert.equal(normalizeLocale('ja-JP'), 'ja');
assert.equal(normalizeLocale('JA_jp'), 'ja');
@@ -72,7 +84,7 @@ test('resolveLocale: ?lang= wins, then the stored choice, then browser languages
resolveLocale({
location: { search: '?lang=xx' },
storage: memoryStorage(),
languages: ['de-DE', 'ru-RU'],
languages: ['ko-KR', 'ru-RU'],
}),
'ru',
);
@@ -80,7 +92,15 @@ test('resolveLocale: ?lang= wins, then the stored choice, then browser languages
resolveLocale({
location: { search: '' },
storage: memoryStorage(),
languages: ['de'],
languages: ['de-DE', 'ru-RU'],
}),
'de',
);
assert.equal(
resolveLocale({
location: { search: '' },
storage: memoryStorage(),
languages: ['ko'],
}),
'en',
);
@@ -146,6 +166,36 @@ test('tn() picks English and Russian plural forms', () => {
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/);
});
test('tn() picks the Portuguese one, many and other forms', () => {
const label = (count) =>
tn(
count,
'POWER UP · {count} KEY WAITING',
'POWER UP · {count} KEYS WAITING',
);
setLocale('pt');
assert.equal(label(0), 'ATIVAR · 0 CHAVE PENDENTE');
assert.equal(label(1), 'ATIVAR · 1 CHAVE PENDENTE');
assert.equal(label(3), 'ATIVAR · 3 CHAVES PENDENTES');
assert.equal(label(1_000_000), 'ATIVAR · 1000000 DE CHAVES PENDENTES');
});
test('tn() picks the four Ukrainian plural forms', () => {
const label = (count) =>
tn(
count,
'POWER UP · {count} KEY WAITING',
'POWER UP · {count} KEYS WAITING',
);
setLocale('uk');
assert.match(label(1), / 1 КЛЮЧ(?![А-ЯҐЄІЇ])/);
assert.match(label(3), / 3 КЛЮЧІ(?![А-ЯҐЄІЇ])/);
assert.match(label(5), / 5 КЛЮЧІВ(?![А-ЯҐЄІЇ])/);
assert.match(label(11), / 11 КЛЮЧІВ(?![А-ЯҐЄІЇ])/);
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯҐЄІЇ])/);
assert.match(label(1.5), / 1\.5 КЛЮЧА(?![А-ЯҐЄІЇ])/);
});
test('t() context separates identical English with different meanings', () => {
assert.equal(t('CLEAR', { context: 'action' }), 'CLEAR');
setLocale('ru');
@@ -210,6 +260,21 @@ test('applyDocumentTranslations translates opted-in text and attributes in place
assert.equal(unknown.textContent, 'No such message');
});
test('applyDocumentTranslations reads data-i18n-context for element text', () => {
const option = fakeElement({
text: 'All',
attributes: { 'data-i18n-context': 'radio tag' },
});
const doc = {
querySelectorAll: (selector) =>
selector === '[data-i18n]' ? [option] : [],
};
setLocale('pt');
applyDocumentTranslations(doc);
assert.equal(option.textContent, t('All', { context: 'radio tag' }));
assert.notEqual(option.textContent, t('All'));
});
test('persistLocaleAndReload keeps the choice in ?lang when storage is blocked', () => {
const calls = [];
const location = {
+17 -2
View File
@@ -16,16 +16,26 @@ import {
normalizeLocale,
writeStoredLocale,
} from './locale.js';
import de from './locales/de.json' with { type: 'json' };
import es from './locales/es.json' with { type: 'json' };
import fr from './locales/fr.json' with { type: 'json' };
import it from './locales/it.json' with { type: 'json' };
import ja from './locales/ja.json' with { type: 'json' };
import pt from './locales/pt.json' with { type: 'json' };
import ru from './locales/ru.json' with { type: 'json' };
import uk from './locales/uk.json' with { type: 'json' };
import zhCN from './locales/zh-CN.json' with { type: 'json' };
import zhHant from './locales/zh-Hant.json' with { type: 'json' };
const CATALOGS = Object.freeze({
de,
es,
fr,
it,
ja,
pt,
ru,
uk,
'zh-CN': zhCN,
'zh-Hant': zhHant,
});
@@ -234,7 +244,9 @@ function normalizeMarkupText(text) {
* 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.
* Also data-i18n-aria-label and data-i18n-placeholder. data-i18n-context
* gives the element's text a context, as t(text, { context }) does:
* <option data-i18n data-i18n-context="radio tag">All</option>
*/
const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([
['data-i18n', null],
@@ -259,7 +271,10 @@ export function applyDocumentTranslations(doc) {
? element.getAttribute(attribute)
: normalizeMarkupText(element.textContent);
if (!source) continue;
const text = t(source);
const context = attribute
? null
: element.getAttribute?.('data-i18n-context');
const text = context ? t(source, { context }) : t(source);
if (text === source) continue;
if (attribute) element.setAttribute(attribute, text);
else element.textContent = text;
+13 -1
View File
@@ -10,9 +10,14 @@ export const DEFAULT_LOCALE = 'en';
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
export const LOCALES = Object.freeze([
'en',
'de',
'es',
'fr',
'it',
'ja',
'pt',
'ru',
'uk',
'zh-CN',
'zh-Hant',
]);
@@ -20,9 +25,14 @@ export const LOCALES = Object.freeze([
/** Each locale named in its own language, for the selector. */
export const LOCALE_NAMES = Object.freeze({
en: 'English',
de: 'Deutsch',
es: 'Español',
fr: 'Français',
it: 'Italiano',
ja: '日本語',
pt: 'Português (Brasil)',
ru: 'Русский',
uk: 'Українська',
'zh-CN': '中文(简体)',
'zh-Hant': '中文(繁體)',
});
@@ -31,7 +41,9 @@ export const LOCALE_NAMES = Object.freeze({
const RTL_LOCALES = Object.freeze([]);
/**
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru', 'ja-JP' → 'ja',
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru',
* 'de-AT' and 'de_CH' → 'de', 'ja-JP' → 'ja',
* 'pt-BR' and 'pt-PT' → 'pt' (Brazilian Portuguese),
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN';
* 'zh-Hant', 'zh-TW', 'zh-HK' and 'zh-MO' → 'zh-Hant'.
* @returns {string|null}
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -66,8 +66,6 @@
"Anime": "Anime",
"Apply high-contrast monochrome film-noir grading.": "Aplica una gradación monocroma de alto contraste estilo film noir.",
"Noir": "Noir",
"Add a cold, snowy whiteout treatment to the scene.": "Añade a la escena un tratamiento frío de ventisca blanca.",
"Snow": "Nieve",
"MAP SOURCE": "FUENTE DEL MAPA",
"Map source": "Fuente del mapa",
"Style": "Estilo",
@@ -2227,6 +2225,8 @@
"GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY no está configurada",
"The Google Maps JavaScript API could not load": "No se pudo cargar la API de Maps JavaScript de Google",
"Weather request timed out": "La petición del clima agotó el tiempo de espera",
"Cyclone request timed out": "La petición de ciclones agotó el tiempo de espera",
"Malformed cyclone snapshot": "Instantánea de ciclones mal formada",
"Malformed weather manifest": "Manifiesto del clima mal formado",
"Weather HTTP {status}": "Clima: HTTP {status}",
"Weather image HTTP {status}": "Imagen del clima: HTTP {status}",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -416,7 +416,6 @@
"Thermal": "Тепловизор",
"Anime": "Аниме",
"Noir": "Нуар",
"Snow": "Снег",
"Current cockpit vision style: {style}. Activate for next style.": "Текущий стиль обзора кабины: {style}. Активируйте, чтобы переключить на следующий.",
"Current style: {style} — click for next": "Текущий стиль: {style} — нажмите для следующего",
"AIRCRAFT": "САМОЛЁТ",
@@ -690,7 +689,6 @@
"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": "Закрепить панель места",
@@ -2263,6 +2261,8 @@
"GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY не задан",
"The Google Maps JavaScript API could not load": "Не удалось загрузить Google Maps JavaScript API",
"Weather request timed out": "Истекло время ожидания запроса погоды",
"Cyclone request timed out": "Истекло время ожидания запроса циклонов",
"Malformed cyclone snapshot": "Некорректный снимок циклонов",
"Malformed weather manifest": "Некорректный манифест погоды",
"Weather HTTP {status}": "Погода: HTTP {status}",
"Weather image HTTP {status}": "Снимок погоды: HTTP {status}",
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1203,6 +1203,8 @@
"Some weather tiles unavailable": "部分天气瓦片不可用",
"Malformed weather manifest": "天气清单格式错误",
"Weather request timed out": "天气请求超时",
"Cyclone request timed out": "气旋请求超时",
"Malformed cyclone snapshot": "气旋快照格式错误",
"Temperature field unavailable": "温度场不可用",
"Mean sea level pressure field unavailable": "平均海平面气压场不可用",
"Wind upstream unavailable": "风场上游数据源不可用",
@@ -1624,7 +1626,6 @@
"Thermal": "热成像",
"Anime": "动漫",
"Noir": "黑白",
"Snow": "雪景",
"Current cockpit vision style: {style}. Activate for next style.": "当前座舱视觉样式:{style}。点击可切换到下一个样式。",
"Current style: {style} — click for next": "当前样式:{style}——点击切换到下一个",
"Collapse Contact panel": "收起目标面板",
@@ -2535,7 +2536,6 @@
"Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "模拟 FLIR 风格的热成像对比。调高 Ironbow 可显示色彩。",
"Apply bright cel-shaded color and illustrated outlines.": "应用明亮的赛璐珞色彩和插画轮廓线。",
"Apply high-contrast monochrome film-noir grading.": "应用高对比度的黑白电影风格调色。",
"Add a cold, snowy whiteout treatment to the scene.": "为场景添加寒冷的暴雪白化效果。",
"MAP SOURCE": "地图源",
"Map source": "地图源",
"Style": "样式",
+2 -2
View File
@@ -1198,6 +1198,8 @@
"Some weather tiles unavailable": "部分天氣瓦片不可用",
"Malformed weather manifest": "天氣清單格式錯誤",
"Weather request timed out": "天氣請求超時",
"Cyclone request timed out": "氣旋請求超時",
"Malformed cyclone snapshot": "氣旋快照格式錯誤",
"Temperature field unavailable": "溫度場不可用",
"Mean sea level pressure field unavailable": "平均海平面氣壓場不可用",
"Wind upstream unavailable": "風場上游數據源不可用",
@@ -1614,7 +1616,6 @@
"Thermal": "熱成像",
"Anime": "動漫",
"Noir": "黑白",
"Snow": "雪景",
"Current cockpit vision style: {style}. Activate for next style.": "當前座艙視覺樣式:{style}。點擊可切換到下一個樣式。",
"Current style: {style} — click for next": "當前樣式:{style}——點擊切換到下一個",
"Collapse Contact panel": "收起目標面板",
@@ -2523,7 +2524,6 @@
"Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "模擬 FLIR 風格的熱成像對比。調高 Ironbow 可顯示色彩。",
"Apply bright cel-shaded color and illustrated outlines.": "應用明亮的賽璐珞色彩和插畫輪廓線。",
"Apply high-contrast monochrome film-noir grading.": "應用高對比度的黑白電影風格調色。",
"Add a cold, snowy whiteout treatment to the scene.": "為場景添加寒冷的暴雪白化效果。",
"MAP SOURCE": "地圖源",
"Map source": "地圖源",
"Style": "樣式",
+655
View File
@@ -1,4 +1,170 @@
{
"de": [
"1090 MHz",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
"ANIME",
"AUTO",
"Alternative",
"Ambient",
"Anime",
"Austin",
"BRG {value}°",
"BRG —",
"Bloom",
"Blues",
"Bus",
"CCTV",
"COCKPIT",
"CRS {value}°",
"CRT",
"Country",
"Cyan",
"Cyber",
"DEST ---°",
"DEST {bearing}",
"Dance",
"Details",
"Dubai",
"EXPORT",
"EXPORT {slot}",
"FALLBACK",
"FILTER",
"FLIR",
"FOV",
"FOV {value}°",
"FT",
"Folk",
"Funk",
"GEO",
"GOOGLE STREET VIEW",
"Global",
"Global · 60°S–60°N",
"Google Street View",
"HDG",
"HDG {value}°",
"Hip-Hop",
"House",
"Hybrid",
"Indie",
"Ironbow",
"Jazz",
"LIVE",
"Landsat 8/9",
"Latin",
"Layout",
"London",
"Los Angeles",
"MIL",
"MINI",
"MISSION",
"MM",
"MONITOR",
"Metal",
"Minimal",
"NAME",
"NASA FIRMS · LIVE",
"NAV",
"NEWS",
"NNW",
"NOIR",
"NORMAL",
"NVG",
"NW",
"New York",
"Noir",
"Normal",
"OK",
"ORBIT",
"ORBIT · ",
"Oldies",
"OpenStreetMap + optional Google Maps Places",
"Operator",
"PAUSE",
"PLAY",
"POS: LIVE",
"POS: {time}Z",
"POSITION {age}",
"PROJ MONITOR",
"Paris",
"Pause",
"Pin",
"Plan",
"Pop",
"Punk",
"RADIO",
"REC",
"REGION",
"RESET",
"Radio",
"Reggae",
"Rock",
"S2 GhostMaps",
"SAR",
"SIG",
"SNAPSHOT",
"SQUAWK {code} · {status}",
"SSW",
"STANDARD",
"START",
"STATION",
"STATUS",
"STATUS · ",
"STD",
"SW",
"SYNC",
"San Francisco",
"Scanlines",
"Scope",
"Sentinel-2",
"Sonar",
"Soul",
"Station",
"Station {id}",
"T minus {seconds}",
"TAGS · {tags}",
"TANKER",
"TEMP",
"Talk",
"Tallinn",
"Techno",
"Trance",
"Turboprop",
"UPSTREAM",
"Upstream HTTP {status}",
"Vignette",
"WHOT/BHOT",
"WIND",
"WNW",
"WSW",
"Washington DC",
"Wind",
"dB",
"feed status|live",
"fire behavior|Minimal",
"ft",
"hPa",
"km",
"km/h",
"min",
"mph",
"radio|NEWS",
"radio|TALK",
"view band|GLOBAL",
"{city} CCTV",
"{county} County",
"{source} HTTP {status}",
"{speed} kn",
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
"{value} KM DOWNRANGE",
"{value} KM/H",
"{value} KM/S",
"ΔN",
"Ⅱ PAUSE",
"▲ {frp} MW"
],
"es": [
"1090 MHz",
"978 MHz UAT",
@@ -124,6 +290,354 @@
"ΔN",
"▲ {frp} MW"
],
"fr": [
"1090 MHz",
"978 MHz UAT",
"ALTITUDE",
"ALTITUDE · FT",
"ANIME",
"AUTO",
"Action",
"Allocation",
"Altitude {altitude}",
"Ambient",
"Anime",
"Austin",
"Aviation / Marine",
"BRG {value}°",
"BRG —",
"Bloom",
"Blues",
"Bus",
"CAL {value}",
"CAL · {badge}",
"CALIBRATION",
"CARGO",
"CAUSE",
"CCTV",
"CCTV OFF",
"CCTV ON",
"COCKPIT",
"COMMERCIAL",
"CONTACT",
"CONTACTS",
"CONTACTS · 250 KM",
"CRS {value}°",
"CRT",
"Country",
"Cyan",
"Cyber",
"Cyclones · NHC / CPHC",
"DENSE",
"DENSE ···",
"DENSE ✕",
"DEST ---°",
"DEST {bearing}",
"DESTINATION",
"Dance",
"Drone",
"ENE",
"ESE",
"EUROPE",
"FLIR",
"FM MHZ",
"FOV",
"FOV {value}°",
"FT",
"Ferry",
"Folk",
"Funk",
"GAIN",
"GEO",
"GOOGLE STREET VIEW",
"Gain",
"Google Street View",
"Grain",
"HDG",
"HDG {value}°",
"Hip-Hop",
"House",
"IMAGE",
"Indie",
"Ironbow",
"Jazz",
"LOCAL",
"Landsat 8/9",
"Landsat 8/9 via HLS",
"Los Angeles",
"MIC",
"MIL",
"MINI",
"MISSION",
"MM",
"MSG/S",
"Metal",
"Minimal",
"Minutes",
"Mode",
"NAV",
"NE",
"NNE",
"NOIR",
"NORMAL",
"NVG",
"New York",
"Noir",
"Normal",
"OFF",
"OK",
"ON",
"ON/OFF",
"Oldies",
"PAUSE",
"POSITION {age}",
"PROJ OFF",
"PROJECTION OFF",
"PROJECTION ON",
"PROPAGATION",
"Paris",
"Pause",
"Plan",
"Pop",
"Punk",
"RADIO",
"REC",
"Radio",
"Reggae",
"Rock",
"S2 GhostMaps",
"SAR",
"SE",
"SIG",
"SOURCE",
"SQUAWK {code} · {status}",
"SRC {value}",
"SSE",
"STANDARD",
"STATION",
"STD",
"San Francisco",
"Satellites",
"Saturation",
"Sonar",
"Soul",
"Source",
"Station",
"Station {id}",
"Style",
"TAGS · {tags}",
"TEMP",
"TYPE",
"Tallinn",
"Techno",
"Tokyo",
"Train",
"Trance",
"USB {rate} msg/s",
"VOLUME",
"VS",
"WHOT/BHOT",
"audio {state}",
"dB",
"ft",
"hPa",
"image",
"installation class|BASE",
"installation class|INSTALLATION",
"km",
"km/h",
"max {value} MW",
"min",
"mini",
"mph",
"nominal",
"satellite",
"standard",
"station",
"voice|OFF",
"{count} MSGS",
"{count} SOURCES",
"{count} messages",
"{n} cyclones",
"{n} satellites",
"{rate} msg/s",
"{value} KM",
"{value} KM/H",
"{value} KM/S",
"ΔE",
"ΔN",
"Ⅱ PAUSE",
"▲ {frp} MW"
],
"it": [
"1090 MHz",
"978 MHz UAT",
"AFRICA",
"AIS: --",
"AIS: {name}",
"ALT {altitude} FT · GS {speed} KT · TRK {track}",
"ANIME",
"ASIA",
"AUTO",
"AUTO HOP OFF",
"AUTO HOP ON",
"Alternative",
"Ambient",
"Anime",
"Area",
"Austin",
"BRG {value}°",
"BRG —",
"Bloom",
"Blues",
"Business jet",
"CAL {value}",
"CAL · {badge}",
"CARGO",
"CCTV",
"CCTV OFF",
"CCTV ON",
"CH {index} / {total}",
"COCKPIT",
"CRS {value}°",
"CRT",
"Country",
"Cyber",
"DEST ---°",
"DEST {bearing}",
"DISPLAY",
"Dance",
"Drone",
"Dubai",
"ENE",
"ESE",
"FLIR",
"FM MHZ",
"FOCUS",
"FOV",
"FOV {value}°",
"FT",
"Folk",
"Funk",
"GEO",
"GOOGLE STREET VIEW",
"Google Street View",
"HDG",
"HDG {value}°",
"Hip-Hop",
"House",
"Indie",
"Ironbow",
"Jazz",
"KTS",
"LIVE",
"Landsat 8/9",
"Layout",
"Los Angeles",
"MIC",
"MIL",
"MINI",
"MM",
"MONITOR",
"MSG/S",
"Metal",
"NASA FIRMS · LIVE",
"NAV",
"NE",
"NNE",
"NOIR",
"NVG",
"New York",
"Noir",
"OCEANIA",
"OFF",
"OK",
"ON",
"ON/OFF",
"Oldies",
"PAYLOAD",
"PLAY",
"POS: LIVE",
"POS: {time}Z",
"PRECIP",
"Pop",
"Punk",
"RADIO",
"REC",
"RESET",
"RESET CAL",
"Radio",
"Reggae",
"Rock",
"S2 GhostMaps",
"SAR",
"SE",
"SIG",
"SNAPSHOT",
"SQUAWK {code} · {status}",
"SSE",
"STANDARD",
"STD",
"STOP",
"SYNC",
"San Francisco",
"Scope",
"Sentinel-2",
"Sonar",
"Soul",
"TEMP",
"TIMEOUT",
"Talk",
"Tallinn",
"Techno",
"Tokyo",
"Tram",
"Trance",
"UPSTREAM",
"USB {rate} msg/s",
"Upstream HTTP {status}",
"V/S {rate} FPM",
"VIEWSHED",
"VIEWSHED ON",
"VOLUME",
"VS",
"WHOT/BHOT",
"Washington DC",
"World",
"audio {state}",
"dB",
"feed status|live",
"ft",
"hPa",
"installation class|BASE",
"km",
"km/h",
"max {value} MW",
"min",
"mini",
"mph",
"radio|TALK",
"satellite",
"standard",
"view band|METRO",
"voice|OFF",
"{count} MSGS",
"{count} feeds live",
"{count} scenes",
"{rate} msg/s",
"{scene} / {pack}: {status}. File: {path}. {attribution} · {license}",
"{source} HTTP {status}",
"{speed} kn",
"{speed}KT",
"{value} KM",
"{value} KM DOWNRANGE",
"{value} KM/H",
"{value} KM/S",
"{value} conf",
"ΔE",
"ΔN",
"▲ {frp} MW"
],
"ja": [
"1090 MHz",
"978 MHz UAT",
@@ -173,6 +687,134 @@
"ΔN",
"▲ {frp} MW"
],
"pt": [
"1090 MHz",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
"ALT {altitude} FT · GS {speed} KT · TRK {track}",
"ALTITUDE",
"ALTITUDE · FT",
"ANIME",
"AUTO",
"Altitude {altitude}",
"Anime",
"Austin",
"BIKE",
"BRG {value}°",
"BRG —",
"Bloom",
"Blues",
"CAL {value}",
"CAL · {badge}",
"CCTV",
"CRS {value}°",
"CRT",
"Country",
"DEST ---°",
"DEST {bearing}",
"Dance",
"Drone",
"Dubai",
"ENE",
"ESE",
"FLIR",
"FLT",
"FM MHZ",
"FOV",
"FOV {value}°",
"FT",
"Folk",
"Funk",
"GEO",
"GOOGLE STREET VIEW",
"Global",
"Global · 60°S–60°N",
"Google Street View",
"HDG",
"HDG {value}°",
"Hip-Hop",
"House",
"Indie",
"Ironbow",
"Jazz",
"LOCAL",
"Landsat 8/9",
"Landsat 8/9 via HLS",
"Layout",
"Los Angeles",
"MIC",
"MIL",
"MINI",
"MM",
"MONITOR",
"MSG/S",
"Metal",
"NAV",
"NE",
"NNE",
"NOIR",
"NORMAL",
"NVG",
"Noir",
"Normal",
"OCEANIA",
"OK",
"POS: {time}Z",
"PRECIP",
"Paris",
"Pop",
"Punk",
"RANGE",
"REC",
"Reggae",
"Rock",
"S2 GhostMaps",
"SAR",
"SE",
"SIG",
"SNAPSHOT",
"SQUAWK {code} · {status}",
"SSE",
"STATUS",
"STATUS · ",
"Sentinel-2",
"Sentinel-2 via HLS",
"Sonar",
"Soul",
"TAGS · {tags}",
"TEMP",
"Tallinn",
"Techno",
"Trance",
"USB {rate} msg/s",
"V/S {rate}",
"VISUAL",
"VOLUME",
"VS",
"WHOT/BHOT",
"WX",
"dB",
"fire confidence|nominal",
"ft",
"hPa",
"installation class|BASE",
"km",
"km/h",
"kt",
"min",
"mini",
"mph",
"nominal",
"view band|GLOBAL",
"{rate} msg/s",
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
"{value} KM/H",
"{value} KM/S",
"ΔN",
"▲ {frp} MW"
],
"ru": [
"AIS: --",
"AIS: {name}",
@@ -191,6 +833,19 @@
"VS",
"WHOT/BHOT"
],
"uk": [
"AIS: --",
"AIS: {name}",
"CCTV",
"CRT",
"FLIR",
"Ironbow",
"Landsat 8/9",
"NVG",
"REC",
"S2 GhostMaps",
"WHOT/BHOT"
],
"zh-CN": [
"1090 MHz",
"978 MHz UAT",
@@ -0,0 +1,58 @@
// The CCTV HUD summary shows the CAL badge, source kind and view band in the
// UI language; English keeps its codes, and unknown source codes stay codes.
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../../i18n/index.js';
import { createPresentation } from './presentation.js';
function summary(sourceKind) {
const camera = {
id: 'c1',
city: 'Austin',
name: 'Cam',
rangeM: 100,
fovDeg: 60,
feedType: 'image',
};
const record = { camera };
const presentation = createPresentation({
state: {
_records: [record],
_healthById: new Map(sourceKind ? [['c1', { sourceKind }]] : []),
_showProjection: false,
_coverageMode: 'sector',
},
services: {},
parts: {
selection: { getActiveRecord: () => record },
model: {
sectorAreaKm2: () => 1,
currentViewContext: () => 'street:1:2',
},
geometry: { coverageNeighborCount: () => 0 },
calibration: { deriveCalBadge: () => 'raw-prior' },
},
source: {},
});
return presentation.buildSummaryText();
}
test('en: CAL, SRC and CONTEXT keep their English codes', () => {
setLocale('en');
assert.match(summary('upstream'), /CAL RAW PRIOR · SRC UPSTREAM/);
assert.match(summary(null), /SRC IMAGE · STREET CONTEXT/);
assert.match(summary('upstream-image'), /SRC UPSTREAM-IMAGE/);
});
test('fr: CAL, SRC and CONTEXT values are translated', () => {
setLocale('fr');
try {
assert.match(
summary('upstream'),
/CAL ESTIM\. INITIALE · SRC AMONT · CONTEXTE RUE/,
);
assert.match(summary('upstream-image'), /SRC UPSTREAM-IMAGE/);
} finally {
setLocale('en');
}
});
+47 -14
View File
@@ -1,5 +1,5 @@
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
import { t } from '../../i18n/index.js';
import { msg, t } from '../../i18n/index.js';
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
import {
@@ -8,8 +8,45 @@ import {
isDatasheetFov,
} from './modelSpecs.js';
/** The camera's zoom bucket (cctv model) in the HUD's view-band words. */
function viewBandLabel(band) {
// The summary line names the CAL badge, the source kind and the view band in
// the page language; the codes underneath stay English.
const CAL_BADGE_LABELS = Object.freeze({
calibrated: msg('CALIBRATED'),
curated: msg('CURATED'),
'raw-prior': msg('RAW PRIOR'),
});
// The same source-kind words as the CCTV panel (src/ui/cctvFrames.js).
const SOURCE_KIND_LABELS = Object.freeze({
snapshot: msg('SNAPSHOT'),
live: msg('LIVE'),
fallback: msg('FALLBACK'),
upstream: msg('UPSTREAM'),
synthetic: msg('SYNTHETIC'),
configured: msg('CONFIGURED'),
seed: msg('SEED'),
image: msg('IMAGE'),
unknown: msg('UNKNOWN'),
});
/** CAL badge id ('raw-prior') as the summary shows it; unknown ids stay codes. */
export function summaryCalLabel(badge) {
const key = String(badge || '');
return CAL_BADGE_LABELS[key]
? t(CAL_BADGE_LABELS[key])
: key.replace('-', ' ').toUpperCase();
}
/** Source kind ('image', 'snapshot') for the summary; HLS, MJPEG… stay codes. */
export function summarySourceLabel(kind) {
const key = String(kind || 'image').toLowerCase();
return SOURCE_KIND_LABELS[key]
? t(SOURCE_KIND_LABELS[key])
: key.toUpperCase();
}
/** View band id ('street', 'city'…) as the HUD names it. */
export function summaryViewBandLabel(band) {
switch (band) {
case 'street':
return t('STREET', { context: 'view band' });
@@ -20,7 +57,7 @@ function viewBandLabel(band) {
case 'global':
return t('GLOBAL', { context: 'view band' });
default:
return band.toUpperCase();
return String(band || '').toUpperCase();
}
}
@@ -88,17 +125,13 @@ export function createPresentation({
: t('ISOLATED VIEW'),
layerState._showProjection ? t('PROJ MONITOR') : t('PROJ OFF'),
layerState._coverageMode === 'viewshed' ? t('VIEWSHED') : null,
t('CAL {value}', {
value: calBadge.replace('-', ' ').toUpperCase(),
t('CAL {value}', { value: summaryCalLabel(calBadge) }),
t('SRC {value}', {
value: summarySourceLabel(
health?.sourceKind || active.camera.feedType || 'image',
),
}),
health?.sourceKind
? t('SRC {value}', {
value: String(health.sourceKind).toUpperCase(),
})
: t('SRC {value}', {
value: String(active.camera.feedType || 'image').toUpperCase(),
}),
t('{value} CONTEXT', { value: viewBandLabel(viewBand) }),
t('{value} CONTEXT', { value: summaryViewBandLabel(viewBand) }),
// Identified hardware: append the model's datasheet specs + attribution.
active.camera.spec
? t('{spec} · SPECS: CCTV-DATABASE.COM', {
+38
View File
@@ -0,0 +1,38 @@
// The CCTV summary line names the CAL badge, the source kind and the view
// band in the page language; unknown codes stay as uppercase codes.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../../i18n/index.js';
import {
summaryCalLabel,
summarySourceLabel,
summaryViewBandLabel,
} from './presentation.js';
function inLocale(locale, run) {
setLocale(locale);
try {
return run();
} finally {
setLocale('en');
}
}
test('English keeps the summary codes', () => {
assert.equal(summaryCalLabel('raw-prior'), 'RAW PRIOR');
assert.equal(summarySourceLabel('image'), 'IMAGE');
assert.equal(summarySourceLabel('hls'), 'HLS');
assert.equal(summaryViewBandLabel('street'), 'STREET');
assert.equal(summaryViewBandLabel('none'), 'NONE');
});
test('Portuguese summary values are translated', () => {
inLocale('pt', () => {
assert.equal(summaryCalLabel('raw-prior'), 'ESTIMATIVA BRUTA');
assert.equal(summaryCalLabel('calibrated'), 'CALIBRADA');
assert.equal(summarySourceLabel('IMAGE'), 'IMAGEM');
assert.equal(summarySourceLabel('mjpeg'), 'MJPEG');
assert.equal(summaryViewBandLabel('street'), 'RUA');
assert.equal(summaryViewBandLabel('city'), 'CIDADE');
});
});
+6 -2
View File
@@ -1,7 +1,11 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
// Shared with the AI tools, so no i18n import: these errors stay English here
// and the cyclones layer translates them where it shows them (translateKnown).
const msg = (text) => text;
export const CYCLONE_RESPONSE_LIMIT = 4 * 1024 * 1024;
const malformed = () => new Error('Malformed cyclone snapshot');
const malformed = () => new Error(msg('Malformed cyclone snapshot'));
const text = (value, max = 160) => {
if (
typeof value !== 'string' ||
@@ -215,7 +219,7 @@ export function createCycloneSource({
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('Cyclone request timed out')),
() => controller.abort(new Error(msg('Cyclone request timed out'))),
timeoutMs,
);
try {
+18 -2
View File
@@ -18,7 +18,13 @@
import * as Cesium from 'cesium';
import { roadLabel } from '../../data/routeSteps.js';
import { t, msg, formatNumber, translateKnown } from '../../i18n/index.js';
import {
t,
msg,
formatNumber,
getLocale,
translateKnown,
} from '../../i18n/index.js';
import { pointerOwnerName } from '../../data/pointerOwnerNames.js';
import {
formatQuantity,
@@ -400,6 +406,16 @@ export function normalizeDirectionsParams(params = {}) {
return out;
}
/**
* A travel-mode word inside a sentence: lower case, except in German, where
* the words are nouns and keep their capital ("Auto", "Fahrrad").
* @param {string} word Translated mode word.
* @returns {string}
*/
function modeWordInSentence(word) {
return getLocale() === 'de' ? word : word.toLowerCase();
}
/**
* Row chips for a given state. Pure so the chip logic is testable without a DOM.
* @param {{mode:string, armed:null|'a'|'b', a:object|null, b:object|null, status:string, route:object|null}} state
@@ -415,7 +431,7 @@ export function directionsRowControls(state) {
state: mode === id ? 'active' : 'idle',
title: t('{mode} — reroute for {modeLower}', {
mode: t(spec.word),
modeLower: t(spec.word).toLowerCase(),
modeLower: modeWordInSentence(t(spec.word)),
}),
params: { mode: id },
}));
+4 -2
View File
@@ -11,7 +11,7 @@ export function createLifecycle({
}) {
const { state } = vesselState;
const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites;
const { holdContinuousRender, releaseContinuousRender } = services.render;
const { releaseContinuousRender } = services.render;
const { ensureGeoidReady } = services.geoid;
const { registerPickOwner, unregisterPickOwner } = services.picking;
@@ -125,6 +125,7 @@ export function createLifecycle({
state.interactionHandlerFactory = null;
state.interactionKeyTarget = null;
state.preRenderRemover = null;
components.rendering.cancelTrailingVisibilityPass();
state.lastVisibilityUpdate = 0;
state.lastFocusUpdate = 0;
state.activeFocusCount = 0;
@@ -164,7 +165,6 @@ export function createLifecycle({
const wasEnabled = state.feed.enabled;
state.feed.enabled = true;
if (!wasEnabled) beginAisSession();
holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2)
const activeViewer = viewer || state.viewer;
components.rendering.ensureCollections(activeViewer);
components.selection.installInteraction(activeViewer);
@@ -208,6 +208,7 @@ export function createLifecycle({
state.feed.enabled = false;
invalidateAisSession();
releaseContinuousRender('ais-vessels');
components.rendering.cancelTrailingVisibilityPass();
unregisterPickOwner('ais-live-vessels');
components.rendering.setVisible(false);
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
@@ -227,6 +228,7 @@ export function createLifecycle({
const activeViewer = viewer || state.viewer;
invalidateAisSession();
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
components.rendering.cancelTrailingVisibilityPass();
if (state.feed.abort) state.feed.abort.abort();
unregisterPickOwner('ais-live-vessels');
components.selection.clearVesselInspection();
+5 -1
View File
@@ -29,7 +29,11 @@ function services() {
forgetSpriteFocus: noop,
},
worldFocus: {},
render: { releaseContinuousRender: noop },
render: {
holdContinuousRender: noop,
releaseContinuousRender: noop,
governorRequestRender: noop,
},
};
}
function setup(source, options = {}) {
+138
View File
@@ -0,0 +1,138 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createVesselLayer } from './index.js';
import { VISIBILITY_UPDATE_MS } from './policy.js';
// Vessels move only when a poll lands, so the layer must let the scene idle:
// it asks for frames for forced passes and one trailing frame after the camera
// stops, and never schedules a frame from a pass the camera did not cause.
const noop = () => {};
function setup() {
let clock = 10_000;
const requests = [];
const holds = new Set();
const timers = new Map();
let nextTimer = 1;
const layer = createVesselLayer({
source: { getSnapshot: async () => ({ records: [] }) },
services: {
context: {
clearSelectedEntityContextForLayer: noop,
registerEntityContext: () => null,
},
trails: {
createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }),
},
labels: {},
picking: { unregisterPickOwner: noop },
overlay: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
geoid: {},
sprites: {},
focus: {
focusNowMs: () => clock,
getFocusTarget: () => null,
focusPassIsNeeded: () => false,
forgetSpriteFocus: noop,
},
worldFocus: {},
render: {
holdContinuousRender: (id) => holds.add(id),
releaseContinuousRender: (id) => holds.delete(id),
governorRequestRender: (reason) => requests.push(reason),
},
},
});
const camera = {
positionWC: { x: 0, y: 0, z: 7_000_000 },
heading: 0,
pitch: -1.2,
roll: 0,
};
layer.testing._setVesselStateForTest({ viewer: { camera, scene: {} } });
layer.testing._setAisRuntimeForTest({
now: () => clock,
setTimeout(fn, ms) {
const id = nextTimer++;
timers.set(id, { fn, at: clock + ms });
return id;
},
clearTimeout: (id) => timers.delete(id),
});
const advance = (ms) => {
clock += ms;
for (const [id, timer] of [...timers]) {
if (timer.at > clock) continue;
timers.delete(id);
timer.fn();
}
};
const frame = () => layer.testing._runVisibilityPassForTest();
return { layer, camera, requests, holds, timers, advance, frame };
}
test('a parked camera schedules no frames after the cadence pass', () => {
const { requests, holds, timers, advance, frame } = setup();
frame();
for (let i = 0; i < 20; i += 1) {
advance(100);
frame();
}
assert.deepEqual(requests, []);
assert.equal(timers.size, 0);
assert.equal(holds.has('ais-vessels'), false);
});
test('a camera that stops inside the cadence gets exactly one trailing frame', () => {
const { layer, camera, requests, timers, advance, frame } = setup();
frame();
advance(100);
camera.heading = 0.5;
frame();
advance(100);
frame();
assert.equal(timers.size, 1, 'one trailing frame, not one per skipped pass');
assert.equal(layer.testing._hasTrailingVisibilityPassForTest(), true);
advance(VISIBILITY_UPDATE_MS);
assert.deepEqual(requests, ['ais-vessels-visibility']);
// The trailing frame runs the regular pass on the pose the camera stopped
// on. Its own frames must not schedule another one.
frame();
for (let i = 0; i < 20; i += 1) {
advance(100);
frame();
}
assert.deepEqual(requests, ['ais-vessels-visibility']);
assert.equal(timers.size, 0);
});
test('a forced pass requests its own frame', () => {
const { layer, requests } = setup();
layer.testing._runVisibilityPassForTest(true);
assert.deepEqual(requests, ['ais-vessels-update']);
});
test('destroy cancels a pending trailing frame', (t) => {
const originalDocument = globalThis.document;
globalThis.document = { getElementById: () => null };
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
});
const { layer, camera, requests, timers, advance, frame } = setup();
frame();
advance(100);
camera.heading = 0.5;
frame();
assert.equal(timers.size, 1);
layer.destroy();
assert.equal(timers.size, 0);
advance(VISIBILITY_UPDATE_MS);
assert.deepEqual(requests, []);
});
+66 -2
View File
@@ -75,7 +75,13 @@ export function createRendering({
focusPassIsNeeded,
advanceSpriteFocus,
focusAlphaNeedsWrite,
spriteFocusAtRest,
} = services.focus;
const {
holdContinuousRender,
releaseContinuousRender,
governorRequestRender,
} = services.render;
function renderRowLimit() {
const configured = Number(options.maxRows);
@@ -196,6 +202,14 @@ export function createRendering({
return icon;
}
// Vessels move only when a poll lands, so the layer does not hold the
// continuous render loop. Its passes run in preRender, which idle mode fires
// only for rendered frames: a forced pass (new data, selection) requests its
// frame, a frame that moved the camera but fell inside the 800 ms cadence
// leaves a trailing frame so the view the camera stopped on is always
// evaluated, and the continuous hold is kept only while focus emphasis is
// animating. The trailing frame is keyed to camera motion alone: the pass's
// own label publication requests a frame, and scheduling on that would loop.
function installRuntime(viewer) {
if (state.preRenderRemover || !viewer) return;
state.preRenderRemover = viewer.scene.preRender.addEventListener(() =>
@@ -203,8 +217,27 @@ export function createRendering({
);
}
function scheduleTrailingVisibilityPass(now) {
if (state.trailingVisibilityTimer) return;
const waitMs = Math.max(
0,
VISIBILITY_UPDATE_MS - (now - state.lastVisibilityUpdate),
);
state.trailingVisibilityTimer = vesselState._aisRuntime.setTimeout(() => {
state.trailingVisibilityTimer = null;
if (state.feed.enabled) governorRequestRender('ais-vessels-visibility');
}, waitMs);
}
function cancelTrailingVisibilityPass() {
if (!state.trailingVisibilityTimer) return;
vesselState._aisRuntime.clearTimeout(state.trailingVisibilityTimer);
state.trailingVisibilityTimer = null;
}
function updateVisibility(force = false) {
if (!state.feed.enabled) return;
if (force) governorRequestRender('ais-vessels-update');
const now = focusNowMs(performance.now());
const focusTarget = getFocusTarget();
const regularPass =
@@ -212,13 +245,30 @@ export function createRendering({
const focusPass =
focusPassIsNeeded(focusTarget, state.activeFocusCount) &&
(force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS);
if (
!regularPass &&
state.viewer?.camera &&
cameraPoseSignature(state.viewer.camera) !== vesselState._lastCamPoseSig
)
scheduleTrailingVisibilityPass(now);
if (!regularPass && !focusPass) return;
if (regularPass) state.lastVisibilityUpdate = now;
if (focusPass) state.lastFocusUpdate = now;
if (!state.records.all.length) {
// No records — flush any lingering card entries (vanished-feed case).
if (regularPass) updateClusteredLabels([]);
if (focusPass) state.activeFocusCount = 0;
// Still record the pose this pass evaluated, or every later frame that
// falls inside the cadence would schedule another trailing frame.
if (regularPass) {
if (state.viewer?.camera)
vesselState._lastCamPoseSig = cameraPoseSignature(
state.viewer.camera,
);
updateClusteredLabels([]);
}
if (focusPass) {
state.activeFocusCount = 0;
releaseContinuousRender('ais-vessels');
}
return;
}
@@ -272,6 +322,9 @@ export function createRendering({
Cesium.Cartesian3.distance(camera.positionWC, position),
});
state.activeFocusCount = result.activeCount;
if (result.activeCount > 0 || result.transitioning)
holdContinuousRender('ais-vessels');
else releaseContinuousRender('ais-vessels');
}
}
@@ -302,6 +355,16 @@ export function createRendering({
const bb = visual?.billboard;
const position = bb?.position || visual?.position;
if (!bb || !position) continue;
// Most of a worldwide feed sits behind the horizon. A hidden sprite with
// no pending focus state and full alpha would advance to exactly what it
// already is, so skip it instead of paying its distance and state work
// every 80 ms while anything is tracked.
if (
bb.show === false &&
spriteFocusAtRest(bb) &&
!focusAlphaNeedsWrite(bb.color?.alpha, 1, params)
)
continue;
const focus = advanceSpriteFocus(bb, {
// Hidden/far-side sprites still finish any pending release so the active
// count remains truthful and they cannot reappear with stale dim alpha.
@@ -510,6 +573,7 @@ export function createRendering({
shipIcon,
installRuntime,
updateVisibility,
cancelTrailingVisibilityPass,
applyVesselFocusDeemphasis,
makeOccluder,
isVisible,
+2
View File
@@ -64,6 +64,8 @@ export function createVesselState({ source, services }) {
/** Test-only key target paired with interactionHandlerFactory. */
interactionKeyTarget: null,
preRenderRemover: null,
/** Pending timer that renders one frame after a cadence-skipped pass. */
trailingVisibilityTimer: null,
lastVisibilityUpdate: 0,
lastFocusUpdate: 0,
/** Sprites whose animated emphasis remains outside the 1.0 deadband. */
+14
View File
@@ -151,6 +151,18 @@ export function createTesting({
};
}
/** Run one preRender visibility/focus pass. Test-only seam. */
function _runVisibilityPassForTest(force = false) {
components.rendering.updateVisibility(force);
}
/** True while a trailing visibility frame is scheduled. Test-only seam. */
function _hasTrailingVisibilityPassForTest() {
return state.trailingVisibilityTimer !== null;
}
/**
* Read lifecycle ownership state without exposing the mutable state object.
* Test-only seam.
@@ -176,5 +188,7 @@ export function createTesting({
_setAisRuntimeForTest,
_getVesselFeedStateForTest,
_getVesselStateForTest,
_runVisibilityPassForTest,
_hasTrailingVisibilityPassForTest,
};
}
+13
View File
@@ -239,6 +239,19 @@ test('no unchanged Realtime tool definition drifts silently', () => {
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").';
camera.parameters.properties.direction.description =
'Required except for orbit (defaults right/clockwise) and stop.';
// Snow is retired from set_visual_style; restore it to keep the legacy pin.
const visualStyle = unchanged.find(
(tool) => tool.name === 'set_visual_style',
).parameters.properties.style;
assert.deepEqual(visualStyle.enum, [
'normal',
'retro',
'surveillance',
'thermal',
'anime',
'noir',
]);
visualStyle.enum = [...visualStyle.enum, 'snow'];
assert.equal(unchanged.length, 14);
const digest = createHash('sha256')
.update(JSON.stringify(unchanged))
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { SCENE_RECIPES, displayShotTitle } from './recipes.js';
import { setLocale } from '../i18n/index.js';
test('an operator-typed title in the built-in Nepal scene stays as typed', () => {
const scene = SCENE_RECIPES.find((r) => r.id === 'bhote-koshi-nepal-scene');
assert.ok(scene, 'built-in bootstrap scene exists');
for (const locale of ['ru', 'es', 'it', 'zh-CN', 'zh-Hant']) {
setLocale(locale);
const title = 'Global Incident Context';
// A shot captured by the operator: no sourcePackId, renamed to this title.
const shown = displayShotTitle(
title,
{ id: 'shot-user', title },
{ id: scene.id },
);
assert.equal(shown, title, `${locale}: got ${shown}`);
}
});
+4
View File
@@ -712,6 +712,10 @@ test('a legacy three-shot Nepal browser project bootstraps to the current 25-sho
assert.deepEqual(scene.shots.slice(0, 3).map(({ camera }) => camera), originalCameras);
assert.equal(scene.appliedShotPacks[0].id, 'bhote-koshi-nepal-evidence-pack');
assert.equal(scene.appliedShotPacks[0].version, 18);
// displayShotTitle trusts these bindings to localize the bootstrap shots.
for (const shot of scene.shots.slice(0, 8)) {
assert.equal(scene.appliedShotPacks[0].shotBindings[shot.title], shot.id);
}
assert.equal(director._selectedSceneId, scene.id);
assert.equal(director._selectedShotId, scene.shots[0].id);
} finally {
+4 -1
View File
@@ -7,6 +7,7 @@ import {
BLOOM_SCALE_VERSION,
decodeBloomIntensity,
} from '../bloom.js';
import { RETIRED_STYLES } from '../visualStylePolicy.js';
/** @constant {number} Current schema version for project migration */
export const PROJECT_VERSION = SCENE_DOCUMENT_VERSION;
@@ -262,7 +263,9 @@ export function normalizeShot(
...(rawShot?.dataPackIds ? { dataPackIds: [...rawShot.dataPackIds] } : {}),
...(rawShot?.move ? { move: deepClone(rawShot.move) } : {}),
visual: {
style: visual.style || 'normal',
style: RETIRED_STYLES.includes(visual.style)
? 'normal'
: visual.style || 'normal',
bloom: normalizeBloomState(bloom, {
projectVersion,
fallbackIntensity: 50,
+28 -9
View File
@@ -1061,25 +1061,44 @@ export function displaySceneTitle(scene) {
* (`sourcePackId`), so a typed title that matches a built-in one stays as
* typed. Without `shot`, the title alone decides.
* @param {string} title
* @param {{sourcePackId?: string}} [shot]
* @param {{id?: string}} [scene] The shot's scene, for projects saved before
* shots recorded their source pack.
* @param {{id?: string, sourcePackId?: string}} [shot]
* @param {{id?: string, appliedShotPacks?: Array<{id?: string, shotBindings?: Object<string, string>}>}} [scene]
* The shot's scene, for projects saved before shots recorded their source
* pack.
* @returns {string}
*/
export function displayShotTitle(title, shot = undefined, scene = undefined) {
const text = typeof title === 'string' ? title : '';
const fromRecipe = shot
? Boolean(shot.sourcePackId) || recipeDeclaresShotTitle(scene?.id, text)
? Boolean(shot.sourcePackId) || recipeDeclaresShotTitle(scene, shot, text)
: true;
if (fromRecipe && BUILTIN_SHOT_TITLES.has(text)) return t(text);
const numbered = /^Shot ([1-9]\d*)$/.exec(text);
return numbered ? t('Shot {n}', { n: numbered[1] }) : text;
}
/** Whether the built-in recipe behind a scene declares a shot with this title. */
function recipeDeclaresShotTitle(sceneId, title) {
const recipe = sceneId ? getSceneRecipeById(sceneId) : null;
return Boolean(
recipe?.cameraPath?.some((keyframe) => keyframe?.title === title),
/** Whether the built-in recipe behind a scene declares this shot's title. */
function recipeDeclaresShotTitle(scene, shot, title) {
const recipe = scene?.id ? getSceneRecipeById(scene.id) : null;
if (!recipe) return false;
const declares = (path) =>
Array.isArray(path) && path.some((keyframe) => keyframe?.title === title);
if (declares(recipe.cameraPath)) return true;
// A bootstrap recipe seeds legacy "Shot N" keyframes; the upgrade replaces
// them with the append recipe's bootstrap shots, which carry no source pack.
// The pack marker binds each of those titles to its shot id, so a shot the
// operator later gave the same title stays as typed.
return SCENE_APPEND_RECIPES.some(
(append) =>
append.legacySceneBootstrap?.targetSceneTitle === recipe.title &&
declares(append.legacySceneBootstrap.cameraPath) &&
Boolean(shot?.id) &&
(Array.isArray(scene.appliedShotPacks)
? scene.appliedShotPacks
: []
).some(
(marker) =>
marker?.id === append.id && marker.shotBindings?.[title] === shot.id,
),
);
}
+38
View File
@@ -4,6 +4,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
SCENE_APPEND_RECIPES,
SCENE_RECIPES,
displaySceneTitle,
displayShotTitle,
@@ -141,3 +142,40 @@ test('the scene context of a shot carries its display title by provenance', asyn
title,
);
});
test('bootstrap shots of a built-in scene localize without a source pack', () => {
setLocale('ru');
const append = SCENE_APPEND_RECIPES.find(
(recipe) => recipe.legacySceneBootstrap?.cameraPath?.length,
);
if (!append) return;
const bootstrap = append.legacySceneBootstrap;
const scene = SCENE_RECIPES.find(
(recipe) => recipe.title === bootstrap.targetSceneTitle,
);
if (!scene) return;
for (const { title } of bootstrap.cameraPath) {
// The upgrade copies these keyframes into the scene without sourcePackId
// and binds each title to its shot in the pack marker.
const upgraded = {
id: scene.id,
appliedShotPacks: [
{ id: append.id, version: 1, shotBindings: { [title]: 'shot-old' } },
],
};
assert.equal(
displayShotTitle(title, { id: 'shot-old', title }, upgraded),
displayShotTitle(title),
);
// Another shot the operator gave the same title stays as typed.
assert.equal(
displayShotTitle(title, { id: 'shot-user', title }, upgraded),
title,
);
// The same title in an operator's own scene stays as typed.
assert.equal(
displayShotTitle(title, { id: 'shot-user', title }, { id: 'scene-user' }),
title,
);
}
});
+7
View File
@@ -107,6 +107,13 @@ test('the HAL example survives the round trip and shrinks to 39 characters', ()
);
});
test('a long-form link to the retired Snow style opens as Normal', () => {
const snow = parseLegacy('v=2&lat=1&lon=2&style=snow&sp=d.90_w.20');
const normal = parseLegacy('v=2&lat=1&lon=2');
assert.equal(snow.style, 'normal');
assert.deepEqual(snow.styleParams, normal.styleParams);
});
test('links from earlier long-form versions keep their historical meaning', () => {
for (const hash of [
'lat=10&lon=20',
+8 -7
View File
@@ -31,6 +31,7 @@ import {
} from '../data/detectionPolicy.js';
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
import { VIEW_STYLES } from '../view/index.js';
import { RETIRED_STYLES } from '../visualStylePolicy.js';
import {
GLOBAL_POST_DEFAULTS,
STYLE_PRESET_DEFAULTS,
@@ -101,7 +102,7 @@ export const SHARE_VIEW_ENUMS = Object.freeze({
'thermal',
'anime',
'noir',
'snow',
'snow', // retired (RETIRED_STYLES): decodes as Normal
]),
mapStack: Object.freeze([
'photoreal',
@@ -221,10 +222,6 @@ export const SHARE_VIEW_STYLE_PARAMS = Object.freeze({
{ key: 'grainAmt', token: 'g', min: 0, max: 1 },
{ key: 'vignetteAmt', token: 'v', min: 0, max: 1 },
]),
snow: freezeAll([
{ key: 'density', token: 'd', min: 0, max: 1 },
{ key: 'wind', token: 'w', min: 0, max: 1 },
]),
});
/**
@@ -1321,8 +1318,12 @@ export function decodeShareView(code, { nowMs = Date.now() } = {}) {
return { ok: false, reason: 'out-of-range' };
}
// A retired style's code stays reserved: the view opens on Normal, and
// parameters written for it are dropped rather than reported as unknown.
const retiredStyle = RETIRED_STYLES.includes(raw.style);
if (raw.style !== undefined && !VIEW_STYLES.includes(raw.style)) {
if (get('style').len) unknown.code('enum:style', raw.style);
if (get('style').len && !retiredStyle)
unknown.code('enum:style', raw.style);
raw.style = undefined;
}
for (const key of ['hudVariant', 'mapStack']) {
@@ -1357,7 +1358,7 @@ export function decodeShareView(code, { nowMs = Date.now() } = {}) {
unknown.code('layer-option', 'without-layers');
if (get('panelState'))
raw.panelState = decodePanels(get('panelState').len, unknown);
if (get('styleParams'))
if (get('styleParams') && !retiredStyle)
raw.styleParams = decodeStyleParams(
get('styleParams').len,
raw.style ?? 'normal',
+26 -2
View File
@@ -2,6 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
LAYER_STATE_REGISTRY,
LAYER_STATE_TOKEN_ALPHABET,
LAYER_STATE_TOKEN_RESERVATIONS,
REGISTERED_LAYER_IDS,
createDefaultLayerState,
@@ -440,7 +441,8 @@ test('defaults travel as frozen meanings that match today’s app defaults', ()
assert.deepEqual(SHARE_VIEW_ENUMS.hudVariant.slice(0, HUD_LAYOUTS.length), [
...HUD_LAYOUTS,
]);
assert.deepEqual(SHARE_VIEW_ENUMS.style, [...VIEW_STYLES]);
// Snow is retired; its code stays reserved (see the retired-style test).
assert.deepEqual(SHARE_VIEW_ENUMS.style, [...VIEW_STYLES, 'snow']);
assert.deepEqual(SHARE_VIEW_ENUMS.mapStack, [...VIEW_MAPS]);
assert.deepEqual(SHARE_VIEW_ENUMS.detectionAllocation, [
...ALLOCATION_STRATEGIES,
@@ -549,7 +551,6 @@ test('the field registry is well formed and permanent', () => {
...common,
styleParams: { contrastAmt: 1.2, grainAmt: 0.5, vignetteAmt: 0.5 },
},
snow: { ...common, styleParams: { density: 0.6, wind: 0.5 } },
});
assert.deepEqual(
SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, e.kind]),
@@ -966,6 +967,29 @@ test('enum names outside the code tables travel as text', () => {
]);
});
test('links to the retired Snow style open as Normal', () => {
const snowCode = SHARE_VIEW_ENUMS.style.indexOf('snow');
assert.equal(snowCode, 6, 'a retired code is never reused');
const params = new WireWriter()
.varint(LAYER_STATE_TOKEN_ALPHABET.indexOf('d'))
.varint(90)
.varint(LAYER_STATE_TOKEN_ALPHABET.indexOf('w'))
.varint(20)
.finish();
for (const writeStyle of [
(writer) => writer.uintField(14, snowCode),
(writer) => writer.textField(14, 'snow'),
]) {
const writer = new WireWriter();
writer.bytes.push(1);
writeStyle(writer);
writer.bytesField(27, params);
const { state, unknownFields } = decodeOk(hashOf(writer.finish()));
assert.deepEqual(state, normalizeShareViewState({}));
assert.equal(unknownFields, undefined);
}
});
/* ---------------- integrity ---------------- */
test('malformed input is rejected with a reason, never thrown', () => {
-97
View File
@@ -1,97 +0,0 @@
import { msg } from '../i18n/marker.js';
/**
* Snow Style — White overlay + Desaturation + Falling particles
* Transforms the world into a winter wonderland
*/
export const snowShader = {
name: 'snow',
uniforms: {
density: { default: 0.6, min: 0, max: 1, label: msg('Density') },
wind: { default: 0.5, min: 0, max: 1, label: msg('Wind') },
},
fragmentShader: /* glsl */ `
uniform sampler2D colorTexture;
uniform vec2 colorTextureDimensions;
uniform float intensity;
uniform float time;
uniform float density;
uniform float wind;
in vec2 v_textureCoordinates;
// Hash function for pseudo-random snow
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
// Layered snow particles
float snowLayer(vec2 uv, float layer) {
float depth = 0.5 + layer * 0.5;
float speed = 0.4 + layer * 0.3;
float size = mix(0.01, 0.025, layer);
float windForce = 0.05 + wind * 0.2;
vec2 snowUV = uv * vec2(1.0, 0.5) * (4.0 + layer * 4.0);
snowUV.y += time * speed;
// Offset is the integral of a swaying velocity sin(time * 0.5 + phase) * windForce,
// so the sway stays bounded however long the app has been running.
snowUV.x -= 2.0 * cos(time * 0.5 + layer * 3.14) * windForce;
vec2 cell = floor(snowUV);
vec2 f = fract(snowUV);
float snow = 0.0;
// Check neighboring cells for smooth particles
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 neighbor = vec2(float(x), float(y));
vec2 point = vec2(hash(cell + neighbor), hash(cell + neighbor + 100.0));
// Animate particle position slightly
point = 0.5 + 0.4 * sin(time * 0.3 + 6.2831 * point);
float d = length(f - neighbor - point);
snow += smoothstep(size, 0.0, d) * depth;
}
}
return snow;
}
void main() {
vec2 uv = v_textureCoordinates;
vec4 color = texture(colorTexture, uv);
// Cool/blue color shift
vec3 cool = color.rgb * vec3(0.85, 0.9, 1.1);
// Desaturate partially
float luma = dot(cool, vec3(0.299, 0.587, 0.114));
vec3 desaturated = mix(cool, vec3(luma), 0.4 * intensity);
// Brighten (snow coverage lightens everything)
vec3 brightened = mix(desaturated, vec3(1.0), 0.15 * intensity);
// Add white frost overlay on brighter areas
float frostMask = smoothstep(0.3, 0.8, luma);
vec3 frosted = mix(brightened, vec3(0.95, 0.97, 1.0), frostMask * 0.25 * intensity);
// Accumulate snow particle layers (density controls amount)
float snow = 0.0;
snow += snowLayer(uv, 0.0) * 0.5;
snow += snowLayer(uv, 0.25) * 0.6;
snow += snowLayer(uv, 0.5) * 0.7;
snow += snowLayer(uv, 0.75) * 0.8;
snow += snowLayer(uv, 1.0) * 1.0;
snow = clamp(snow * (0.4 + density * 1.2), 0.0, 1.0);
// Composite snow particles
vec3 result = frosted + snow * 0.8 * intensity;
// Subtle fog in lower areas
float fog = smoothstep(0.0, 0.4, 1.0 - uv.y) * 0.1 * intensity;
result = mix(result, vec3(0.85, 0.88, 0.95), fog);
out_FragColor = vec4(mix(color.rgb, result, intensity), color.a);
}
`,
};
+1 -1
View File
@@ -1598,7 +1598,7 @@ export class StyleManager extends ShellFacade {
* 2. Crossfades the new shader stage intensity to 1.
* 3. Applies style preset defaults (bloom/sharpen/HUD) if applyPreset is true.
* 4. Updates button highlights, style indicator, slider panel, HUD, and detection overlay.
* @param {string} styleName - Target style ('normal'|'retro'|'surveillance'|'thermal'|'anime'|'noir'|'snow').
* @param {string} styleName - Target style ('normal'|'retro'|'surveillance'|'thermal'|'anime'|'noir').
* @param {object} [options]
* @param {boolean} [options.applyPreset=true] - Whether to apply STYLE_PRESET_DEFAULTS for the new style.
* @param {boolean} [options.userInitiated=false] - Whether a direct user preset choice owns the Cyber exit style.
-1
View File
@@ -5,7 +5,6 @@ const STYLE_KEYS = Object.freeze({
4: 'thermal',
5: 'anime',
6: 'noir',
7: 'snow',
});
/**
-2
View File
@@ -268,7 +268,6 @@ export function setVisionMode(mode, { revealParameters = false } = {}) {
thermal: t('FLIR'),
anime: t('ANIME'),
noir: t('NOIR'),
snow: t('SNOW'),
};
const names = {
optical: t('Normal'),
@@ -277,7 +276,6 @@ export function setVisionMode(mode, { revealParameters = false } = {}) {
thermal: t('Thermal'),
anime: t('Anime'),
noir: t('Noir'),
snow: t('Snow'),
};
if (this.visionCurrent) {
this.visionCurrent.dataset.cockpitVision = next;
+26 -20
View File
@@ -105,7 +105,6 @@ test('visual styles retain Cyber through real preset application without droppin
'normal',
'noir',
'anime',
'snow',
'thermal',
'retro',
]) {
@@ -162,8 +161,8 @@ test('a preset explicitly selected in Cyber survives exit and resets for re-entr
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
owner.setStyle('snow', { userInitiated: true });
assert.equal(owner.activeStyle, 'snow');
owner.setStyle('anime', { userInitiated: true });
assert.equal(owner.activeStyle, 'anime');
assert.equal(owner._preCyberStyle, 'noir');
assert.equal(owner._cyberStyleUserSelected, true);
@@ -172,16 +171,16 @@ test('a preset explicitly selected in Cyber survives exit and resets for re-entr
assert.equal(owner._preCyberStyle, 'noir');
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(hud.getVariant(), 'operator');
assert.equal(owner.activeStyle, 'snow');
assert.equal(owner.activeStyle, 'anime');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
// A later untouched Cyber round trip restores the new pre-Cyber preset.
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner._preCyberStyle, 'snow');
assert.equal(owner._preCyberStyle, 'anime');
owner._setHudVariant('minimal', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'snow');
assert.equal(owner.activeStyle, 'anime');
});
test('an explicit same-value FLIR choice is preserved on Cyber exit', (t) => {
@@ -200,15 +199,15 @@ test('an authoritative same-Cyber reset clears local preset ownership', (t) => {
const { owner } = styleOwner(t, 'tactical');
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
owner.setStyle('snow', { userInitiated: true });
owner.setStyle('anime', { userInitiated: true });
assert.equal(owner._cyberStyleUserSelected, true);
owner._setHudVariant('cyber');
assert.equal(owner.activeStyle, 'snow');
assert.equal(owner.activeStyle, 'anime');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'snow');
assert.equal(owner.activeStyle, 'anime');
});
test('Display and voice HUD routes preserve explicit Cyber entry and exit authority', async (t) => {
@@ -226,10 +225,10 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
const display = makeOwner();
const hudLayout = new EventTarget();
hudLayout.value = 'cyber';
const snowButton = new EventTarget();
snowButton.dataset = { style: 'snow' };
const animeButton = new EventTarget();
animeButton.dataset = { style: 'anime' };
const controls = bindDisplayControls({
elements: { hudLayout, styleButtons: [snowButton] },
elements: { hudLayout, styleButtons: [animeButton] },
actions: {
setHudLayout: (value) => display.owner.setHudLayout(value),
setStyle: (value) =>
@@ -239,12 +238,12 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
hudLayout.dispatchEvent(new Event('change'));
assert.equal(display.hud.getVariant(), 'cyber');
assert.equal(display.owner.activeStyle, 'thermal');
snowButton.dispatchEvent(new Event('click'));
assert.equal(display.owner.activeStyle, 'snow');
animeButton.dispatchEvent(new Event('click'));
assert.equal(display.owner.activeStyle, 'anime');
hudLayout.value = 'operator';
hudLayout.dispatchEvent(new Event('change'));
assert.equal(display.hud.getVariant(), 'operator');
assert.equal(display.owner.activeStyle, 'snow');
assert.equal(display.owner.activeStyle, 'anime');
controls.destroy();
const voice = makeOwner();
@@ -263,13 +262,13 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
assert.equal(entered.ok, true);
assert.equal(voice.hud.getVariant(), 'cyber');
assert.equal(voice.owner.activeStyle, 'thermal');
const selected = await runVoiceAction('set_visual_style', { style: 'snow' });
const selected = await runVoiceAction('set_visual_style', { style: 'anime' });
assert.equal(selected.ok, true);
assert.equal(voice.owner.activeStyle, 'snow');
assert.equal(voice.owner.activeStyle, 'anime');
const exited = await runVoiceAction('set_hud', { layout: 'operator' });
assert.equal(exited.ok, true);
assert.equal(voice.hud.getVariant(), 'operator');
assert.equal(voice.owner.activeStyle, 'snow');
assert.equal(voice.owner.activeStyle, 'anime');
});
test('scope choice and expanded feather survive every HUD layout and both restore paths', async (t) => {
@@ -301,11 +300,18 @@ test('scope choice and expanded feather survive every HUD layout and both restor
assert.equal(owner._scopeFeatherSlider.value, '200');
});
test('a scene shot saved with the retired Snow style plays on Normal', async (t) => {
const { owner } = styleOwner(t, 'tactical');
owner.setStyle('noir');
await owner.applyVisualState({ style: 'snow' });
assert.equal(owner.activeStyle, 'normal');
});
test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => {
const { owner, hud } = styleOwner(t, 'tactical');
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
owner.setStyle('snow', { userInitiated: true });
owner.setStyle('anime', { userInitiated: true });
assert.equal(owner._cyberStyleUserSelected, true);
await owner.applyVisualState({ style: 'retro', hud: { variant: 'cyber' } });
@@ -319,7 +325,7 @@ test('scene and share HUD transitions do not restore a local pre-Cyber preset',
owner.setStyle('surveillance');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner._preCyberStyle, 'surveillance');
owner.setStyle('snow', { userInitiated: true });
owner.setStyle('anime', { userInitiated: true });
await owner.restoreShareState({
style: 'thermal',
hudVariant: 'cyber',
+5 -3
View File
@@ -136,9 +136,11 @@ export function layerKeyRequirementTooltip(layer = {}) {
const ROW_TEXT_SEPARATOR = ' · ';
// A short Latin code joined by hyphens. Longer runs (a URL, a token in an
// error) keep their break opportunities so they cannot overflow the rail.
const HYPHENATED_CODE = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+/g;
// A short Latin code joined by hyphens: every part after a hyphen is capitals
// or digits (ADS-B, RTL-SDR, GTFS-RT). A compound such as "ALPR-Kameras" is a
// word, not a code, and may break at its hyphen. Longer runs (a URL, a token
// in an error) keep their break opportunities so they cannot overflow the rail.
const HYPHENATED_CODE = /\b[A-Za-z0-9]+(?:-[A-Z0-9]+)+(?![A-Za-z0-9])/g;
const HYPHENATED_CODE_MAX = 16;
/** A span for one run of row text; `keep` stops the browser breaking inside it. */
+9
View File
@@ -324,6 +324,15 @@ test('row text keeps a hyphenated code whole and never starts a line with the se
assert.equal(plain.textContent, 'Satellites');
assert.equal(plain.children.length, 0);
const compound = element();
setRowText(compound, 'Kartierte ALPR-Kameras · ADS-B-Flüge');
assert.equal(text(compound), 'Kartierte ALPR-Kameras · ADS-B-Flüge');
assert.deepEqual(
kept(compound),
[' ·', 'ADS-B'],
'a compound may break at its hyphen; the code inside it stays whole',
);
const long = element();
setRowText(long, 'see https-proxy-endpoint-config-x');
assert.deepEqual(kept(long), [], 'a long hyphenated run may still wrap');
+23 -8
View File
@@ -31,6 +31,9 @@ const MANY = Object.keys(NAMES);
const codes = (rows) => rows.map((row) => row.code);
// Few enough languages that the picker has no search field.
const UNSEARCHED = ['en', 'es', 'ru', 'uk', 'zh-CN', 'zh-Hant'];
test('the current language comes first, the rest by their own names', () => {
assert.deepEqual(
codes(
@@ -40,7 +43,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['ru', 'en', 'es', 'zh-CN', 'zh-Hant', 'ja'],
['ru', 'uk', 'de', 'en', 'es', 'fr', 'it', 'pt', 'zh-CN', 'zh-Hant', 'ja'],
);
assert.deepEqual(
codes(
@@ -50,7 +53,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['es', 'en', 'ru', 'zh-CN', 'zh-Hant', 'ja'],
['es', 'de', 'en', 'fr', 'it', 'pt', 'ru', 'uk', 'zh-CN', 'zh-Hant', 'ja'],
);
assert.deepEqual(
codes(
@@ -60,7 +63,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['en', 'es', 'ru', 'zh-CN', 'zh-Hant', 'ja'],
['en', 'de', 'es', 'fr', 'it', 'pt', 'ru', 'uk', 'zh-CN', 'zh-Hant', 'ja'],
);
const rows = buildLocaleOptions({
locales: MANY,
@@ -95,6 +98,8 @@ test('each row gets the interface-language name for search, unless it only repea
const byCode = Object.fromEntries(rows.map((row) => [row.code, row]));
assert.equal(byCode.en.localName, 'английский');
assert.equal(byCode.es.localName, 'испанский');
assert.equal(byCode.de.localName, 'немецкий');
assert.equal(byCode.fr.localName, 'французский');
assert.equal(byCode.ru.localName, '', 'русский only repeats Русский');
const english = buildLocaleOptions({
locales: LOCALES,
@@ -103,6 +108,7 @@ test('each row gets the interface-language name for search, unless it only repea
localName: createLocalNameReader('en'),
});
assert.equal(english.find((row) => row.code === 'es').localName, 'Spanish');
assert.equal(english.find((row) => row.code === 'fr').localName, 'French');
});
test('without Intl.DisplayNames the interface-language name is simply left out', () => {
@@ -165,7 +171,10 @@ test('search folds case and diacritics, and matches either name or the code', ()
test('the search field appears only above the threshold', () => {
assert.equal(LOCALE_SEARCH_THRESHOLD, 8);
assert.equal(createLocalePickerModel({ current: 'en' }).searchable, false);
assert.equal(
createLocalePickerModel({ current: 'en' }).searchable,
LOCALES.length > LOCALE_SEARCH_THRESHOLD,
);
const eight = MANY.slice(0, 8);
assert.equal(
createLocalePickerModel({
@@ -250,6 +259,7 @@ test('type-ahead cycles by first letter and refines with more letters', () => {
test('keyboard model: open on the current language, move, choose, close', () => {
const model = createLocalePickerModel({
locales: UNSEARCHED,
current: 'ru',
localName: createLocalNameReader('ru'),
});
@@ -261,9 +271,10 @@ test('keyboard model: open on the current language, move, choose, close', () =>
model.openList();
assert.equal(model.activeOption.code, 'ru');
assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true });
assert.equal(model.activeOption.code, 'en');
// Russian collation puts the other Cyrillic name next.
assert.equal(model.activeOption.code, 'uk');
model.keydown({ key: 'End' });
assert.equal(model.activeOption.code, 'ja');
assert.equal(model.activeOption.code, 'zh-Hant');
model.keydown({ key: 'Home' });
assert.equal(model.activeOption.code, 'ru');
model.keydown({ key: 'ArrowUp' });
@@ -275,7 +286,7 @@ test('keyboard model: open on the current language, move, choose, close', () =>
model.keydown({ key: 'ArrowDown' });
assert.deepEqual(model.keydown({ key: ' ' }), {
handled: true,
choose: 'en',
choose: 'uk',
});
assert.deepEqual(model.keydown({ key: 'Escape' }), {
handled: true,
@@ -292,7 +303,11 @@ test('keyboard model: open on the current language, move, choose, close', () =>
test('keyboard model: type-ahead resets after a pause and ignores shortcuts', () => {
let clock = 0;
const model = createLocalePickerModel({ current: 'en', now: () => clock });
const model = createLocalePickerModel({
locales: UNSEARCHED,
current: 'en',
now: () => clock,
});
model.openList();
assert.deepEqual(model.keydown({ key: 'e' }), { handled: true });
assert.equal(model.activeOption.code, 'es');
-1
View File
@@ -12,7 +12,6 @@ const STYLE_LABELS = Object.freeze({
thermal: msg('FLIR'),
anime: msg('ANIME'),
noir: msg('NOIR'),
snow: msg('SNOW'),
});
const DETECTION_MODE_LABELS = Object.freeze({
+3 -2
View File
@@ -518,14 +518,15 @@ html:not([lang='en']) .bhote-event-story-current strong {
-webkit-line-clamp: 2;
}
/* Phase / time / sources run long in translation: allow a second line. */
/* Phase / time / sources / imagery note run long in translation: allow up to
three lines so the last status is not cut off. */
html:not([lang='en']) .bhote-event-story-current small {
display: -webkit-box;
margin-top: 3px;
text-overflow: clip;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
-webkit-line-clamp: 3;
}
.bhote-event-actions {
+2 -1
View File
@@ -730,7 +730,8 @@ html:not([lang='en']) .cockpit-roll-token.is-separator {
}
.cockpit-vision-controls {
display: grid;
grid-template-columns: 48px 58px 48px;
/* The current style grows for a long translated name (ЗВИЧАЙНИЙ). */
grid-template-columns: 48px minmax(58px, max-content) 48px;
align-items: stretch;
gap: 2px;
margin-top: 2px;
-6
View File
@@ -747,12 +747,6 @@
.btn-icon::before {
content: '◐';
}
:root[data-ui-theme='cyber']
#command-dock
.style-btn[data-style='snow']
.btn-icon::before {
content: '❄︎';
}
:root[data-ui-theme='cyber'] :where(#bloom-toggle, #sharpen-toggle) {
font-size: 0;
+6
View File
@@ -688,6 +688,12 @@ html:not([lang='en']) .data-count {
text-wrap: balance;
}
/* The status column keeps its width, so a wide chip (WIRD AKTIVIERT) never
overlaps the name; the name wraps in the room left beside it. */
html:not([lang='en']) .data-toggle-right {
flex: none;
}
/* Row text pieces that must not break inside: a hyphenated code (ADS-B) and
the space before a " · " separator (see setRowText in layerPanel.js). */
html:not([lang='en']) .data-keep {
-5
View File
@@ -43,11 +43,6 @@
<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>
<span class="btn-icon">❄</span>
<span class="btn-label" data-i18n>Snow</span>
<span class="btn-key">7</span>
</button>
</div>
<section class="map-source-section" aria-labelledby="map-source-label">
<div class="map-source-heading">
+1 -1
View File
@@ -122,7 +122,7 @@
<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 class="gev-field" id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label>
<option value="all" data-i18n>All</option>
<option value="all" data-i18n data-i18n-context="radio tag">All</option>
</select>
</div>
<div id="radio-station-card" class="radio-station-card" aria-live="polite">
+4 -4
View File
@@ -77,7 +77,7 @@ test('construction is inert and initialization creates one owned pipeline', () =
assert.equal(f.frames.size, 0);
f.effects.initStyles();
f.effects.initPostProcess();
assert.equal(f.stages.size, 7);
assert.equal(f.stages.size, 6);
assert.ok(
Object.values(f.effects.stages).every(
(stage) => !stage.enabled && stage.uniforms.intensity === 0,
@@ -85,7 +85,7 @@ test('construction is inert and initialization creates one owned pipeline', () =
);
f.effects.initStyles();
f.effects.initPostProcess();
assert.equal(f.stages.size, 7);
assert.equal(f.stages.size, 6);
f.effects.destroy();
});
@@ -252,7 +252,7 @@ test('stop revokes pending work immediately but retains stages until final destr
assert.equal(f.frames.size, 0);
assert.equal(
f.stages.size,
7,
6,
'Context and Cockpit may still be releasing these stages',
);
const intensity = f.effects.stages.retro.uniforms.intensity;
@@ -281,7 +281,7 @@ test('destroying one instance does not remove another pipeline or clock', () =>
a.effects.destroy();
b.tick(250);
assert.equal(a.stages.size, 0);
assert.equal(b.stages.size, 6);
assert.equal(b.stages.size, 5);
assert.equal(b.effects.stages.retro.uniforms.intensity, 0.5);
b.effects.destroy();
});
+2 -2
View File
@@ -73,13 +73,13 @@ function shortcuts() {
return { documentRef, searchInput, calls, controller, press };
}
test('number keys retain the seven style mappings', () => {
test('number keys retain the six style mappings', () => {
const f = shortcuts();
for (const key of ['1', '2', '3', '4', '5', '6', '7', '8', 'Space'])
f.press(key);
assert.deepEqual(
f.calls,
['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow'].map(
['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir'].map(
(style) => ['setStyle', style],
),
);
-3
View File
@@ -1,7 +1,6 @@
import { retroShader } from '../styles/retro.js';
import { animeShader } from '../styles/anime.js';
import { noirShader } from '../styles/noir.js';
import { snowShader } from '../styles/snow.js';
import { nightVisionShader } from '../styles/surveillance.js';
import { thermalShader } from '../styles/thermal.js';
import { BLOOM_INTENSITY_DEFAULT } from '../bloom.js';
@@ -16,7 +15,6 @@ export const STYLES = {
thermal: thermalShader,
anime: animeShader,
noir: noirShader,
snow: snowShader,
};
/**
@@ -156,5 +154,4 @@ export const STYLE_STATUS_LABELS = {
thermal: msg('FLIR'),
anime: msg('ANIME'),
noir: msg('NOIR'),
snow: msg('SNOW'),
};
+4 -2
View File
@@ -1,6 +1,7 @@
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
import { syncScopeControls } from './displayControls.js';
import { STYLE_STATUS_LABELS } from './visualPresets.js';
import { RETIRED_STYLES } from '../visualStylePolicy.js';
import { syncSegmentedControl } from './segmentedControl.js';
import { t } from '../i18n/index.js';
import { UiLifetime } from './uiLifetime.js';
@@ -1272,8 +1273,9 @@ export class VisualSettings {
const superseded = () => typeof isCurrent === 'function' && !isCurrent();
if (superseded()) return false;
if (state.style && state.style !== this.activeStyle) {
this.setStyle(state.style, { applyPreset: false });
const style = RETIRED_STYLES.includes(state.style) ? 'normal' : state.style;
if (style && style !== this.activeStyle) {
this.setStyle(style, { applyPreset: false });
}
const bloomState = state.bloom || {};
+7 -4
View File
@@ -11,6 +11,7 @@ import {
decodeLayerStateParams,
encodeLayerStateParams,
} from '../data/layerState.js';
import { RETIRED_STYLES } from '../visualStylePolicy.js';
/** Visual styles by internal name, with the name share links use. */
export const STYLE_URL_NAMES = Object.freeze({
@@ -20,7 +21,6 @@ export const STYLE_URL_NAMES = Object.freeze({
thermal: 'flir',
anime: 'anime',
noir: 'noir',
snow: 'snow',
});
const STYLES_BY_URL_NAME = Object.freeze(
@@ -254,6 +254,7 @@ export function createView({
} = {}) {
if (!Number.isFinite(camera?.lat) || !Number.isFinite(camera?.lon))
throw new TypeError('A view needs a camera lat and lon');
if (RETIRED_STYLES.includes(style)) style = 'normal';
if (style !== null && !VIEW_STYLES.includes(style))
throw new TypeError(`Unknown view style: ${style}`);
if (map !== null && !VIEW_MAPS.includes(map))
@@ -370,9 +371,11 @@ export function viewFromParams(params) {
pitch_deg: number('pitch'),
},
layers: state?.enabledLayerIds ?? [],
style: Object.hasOwn(STYLES_BY_URL_NAME, params.get('style'))
? STYLES_BY_URL_NAME[params.get('style')]
: null,
style: RETIRED_STYLES.includes(params.get('style'))
? 'normal'
: Object.hasOwn(STYLES_BY_URL_NAME, params.get('style'))
? STYLES_BY_URL_NAME[params.get('style')]
: null,
map: VIEW_MAPS.includes(map) ? map : null,
follow,
annotations: annotationsFromParams(params),
+8
View File
@@ -127,6 +127,14 @@ test('annotations travel in the link, keeping only what the app draws', async ()
assert.equal(viewToParams(many).has('an'), false);
});
test('a view naming the retired Snow style opens on Normal', () => {
const camera = { lat: 1, lon: 2 };
assert.equal(createView({ camera, style: 'snow' }).style, 'normal');
const view = viewFromParams(new URLSearchParams('lat=1&lon=2&style=snow'));
assert.equal(view.style, 'normal');
assert.equal(viewToParams(view).get('style'), 'normal');
});
test('inherited and unknown style keys do not corrupt a restored view', () => {
for (const style of [
'constructor',
+7
View File
@@ -0,0 +1,7 @@
/**
* Browser-free style vocabulary shared by views, share links and the UI.
* Styles the app no longer offers, by the name share links used: a view,
* link, scene or shot that names one opens on Normal instead of keeping
* whatever style was already showing.
*/
export const RETIRED_STYLES = Object.freeze(['snow']);
+1 -9
View File
@@ -248,15 +248,7 @@ const schemas = [
properties: {
style: {
type: 'string',
enum: [
'normal',
'retro',
'surveillance',
'thermal',
'anime',
'noir',
'snow',
],
enum: ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir'],
},
},
required: ['style'],
+14 -1
View File
@@ -45,7 +45,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// S2 GhostMaps extends the generated layer enums.
// Panel adds named voice targets and timeout; shared Panel/Cockpit guidance
// is consolidated without changing either feature's arguments.
'9ee82c4214499280c208c93751b19d7b724c81b7727a2d0c6dab0184749fbe15',
// Snow leaves the set_visual_style enum.
'724a82b538a12a994401fc941e175625b032bfb96109934e091d3ef5b9600909',
);
});
@@ -169,6 +170,18 @@ test('all legacy action arguments are byte-identical after removing the delibera
// Cyber adds one HUD layout.
const hud = property('set_hud').layout;
hud.enum = hud.enum.filter((layout) => layout !== 'cyber');
// Snow is retired from set_visual_style; restore it to keep the legacy pin.
const visualStyle = legacy.find((tool) => tool.name === 'set_visual_style')
.parameters.properties.style;
assert.deepEqual(visualStyle.enum, [
'normal',
'retro',
'surveillance',
'thermal',
'anime',
'noir',
]);
visualStyle.enum = [...visualStyle.enum, 'snow'];
// Derived by applying the same removals to the 4b56d0e9 actionSchemas.
assert.equal(
createHash('sha256').update(JSON.stringify(legacy)).digest('hex'),
-1
View File
@@ -66,7 +66,6 @@ const ALLOWED_STYLES = new Set([
'thermal',
'anime',
'noir',
'snow',
]);
const PANEL_ALIASES = new Map([
['data', 'data-panel'],
+4 -1
View File
@@ -963,7 +963,10 @@ export function progressStepLabel(step, label) {
: step
? spokenLabel(step, 24)
: t('Working');
return label ? `${base}: ${spokenLabel(label, 32)}` : base;
// The join is a catalog string so each language spaces its colon.
return label
? t('{name}: {state}', { name: base, state: spokenLabel(label, 32) })
: base;
}
/**
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more