Merge remote-tracking branch 'upstream/main' into pr1469-fix

This commit is contained in:
Said Turksever committed 2026-10-30 15:17:15 +00:00
commit 828417f98b
162 files changed
+22045 -1988

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.
---
@@ -0,0 +1,3 @@
- Remove the Operator HUD layout. Display > HUD > Layout and the HUD voice
action now offer Tactical, Minimal and Cyber; share links, scenes and saved
views that name Operator open in Tactical (asturksever, #1467).
+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).
@@ -0,0 +1,7 @@
- Remove the Radio icon from the Context panel header, along with the compact
Radio player it opened; Radio stays in Context as its own section, and
Cockpit's compact Radio is unchanged. Context is also easier to read: the
Contacts / Space Missions modes are one segmented control, labels and
guidance are larger and higher contrast, the standby hint is plain text
instead of a dashed box, and the panel draws no glow halo. Cyber keeps its
own framed buttons and red titles (asturksever, #1471).
+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).
+4
View File
@@ -0,0 +1,4 @@
- Add Japanese (ja) to the language picker, with year-first dates, Japanese
counters, unit words joined to their numbers (5分前, 1時間5分) and CJK label
rules for Japanese text. Based on translations by @abiyeenzo (#913)
(German, @gekh, #1478).
+4
View File
@@ -0,0 +1,4 @@
- Draw only the CCTV cameras in view, and thin them to one per ground cell when
zoomed out. Dragging the globe with the default 4,000-camera catalog no
longer stalls, and the camera picker lists the cameras in view with a count
of the ones outside it (samehkhamis, #1485).
+44 -37
View File
@@ -237,7 +237,7 @@ from 0–200%. OFF disables the slider while retaining its value. HUD layout
changes preserve both settings; share links and scenes restore them. The
historical missing-`scf` fallback remains 35%; explicit 0% keeps the hard edge.
With Scope OFF, Tactical, Operator and Minimal add text-sized black backplates
With Scope OFF, Tactical and Minimal add text-sized black backplates
at 70% opacity behind telemetry, vertical metadata and scene chrome. Original
HUD text color, glow and opacity are preserved. Scope OFF brightens the tagline
and Active Style label with the shared secondary text color. The title plate
@@ -247,6 +247,15 @@ the original HUD presentation; Cyber retains its existing skin.
`node scripts/qa-scope-hud.mjs` checks native input, range, layout persistence,
restoration and both Scope presentations. `QA_SCREENSHOT_DIR` saves screenshots.
## HUD layouts — October 29, 2026
Display > HUD > Layout offers Tactical, Minimal and Cyber; the `set_hud` voice
action accepts the same three. The Operator layout is retired. Share links
keep its enum code so older links still parse, and a link, scene or saved state
that names Operator restores the default Tactical layout. Operator was the
only layout that showed the HUD's top banner bar, so its markup and styles are
removed too.
## Cyber HUD — September 30, 2026
Display > HUD > Layout includes Cyber, also available through the HUD voice
@@ -262,8 +271,8 @@ red hover border in both map and cockpit, so clipping does not hide the highligh
Cyber's map-side right rail opens one panel at a time while keeping collapsed
launchers available. Display, CCTV and Context scroll their contents inside fixed
headers and decorative frames. The narrow-screen rail remains scrollable to reach
each panel. Radio retains the shared nested Context player and compact
header disclosure, without relocating playback controls on theme changes.
each panel. Radio retains the shared nested Context player without relocating
playback controls on theme changes.
Voice help/error popups and Location/Visual Presets pins extend beyond their
Cyber frames without being clipped: the shapes decorate non-interactive
@@ -985,6 +994,15 @@ directory. Every `/api/cctv` route refuses cross-site browser requests but
accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded
request skips the key-bearing Street View fallback and gets the placeholder.
The CCTV layer draws only the cameras on or near the screen. Zoomed out, it
keeps one camera per ground cell sized to the view, preferring a healthy feed,
and the cells are anchored to the ground so a pan keeps the same cameras. The
active camera always shows. Ground placement and coverage geometry load only
for the cameras in view, with at most two attempts per camera and view regime.
The view pass reruns while the camera moves, paced by its own cost. The camera
picker lists the cameras in view, active camera first, and closes with the
count of cameras outside the view.
Traffic owns its rendered-surface observer only while enabled, removes it on disable/destroy, and caches surface identity without allocating on unchanged frames.
Traffic and bikeshare expose factories through `./layers/traffic` and
@@ -1408,8 +1426,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.
@@ -1524,10 +1544,6 @@ Automatic collapse remains presentation only, prioritizes the latest explicit
panel, and honors keyboard focus on the right. Mobile layout still releases
desktop allocation styles. Stable Display allocation avoids unnecessary style
writes. This extraction does not change panel positions or layout defaults.
Context expansion through the panel owner or adaptive right-rail restoration
closes an open compact Radio disclosure before Context/Space Missions becomes
visible. The mini remains available while Context is collapsed. This fixes the
Radio-overlap path reported in #804 without changing z-index or layout policy.
The right rail skips panels hidden by other CSS (computed `display: none`), but
keeps the collapsed panels its own exclusive presentation hides, so automatic
collapse can still be undone.
@@ -2016,7 +2032,7 @@ they do not reduce the materialized entity count or establish an FPS gain.
rotation and mission-marker highlight as pointer hover. Focus alone does not
select the mission; Enter or Space performs selection. Keyboard and pointer
preview ownership remain independent when the pointer is parked over the list.
- Radio power controls in the full, compact, and Cockpit surfaces, Search Nearby
- Radio power controls in the full and Cockpit surfaces, Search Nearby
Sites, and Clear Selected Layers remain focusable while lifecycle work is in
progress. They announce busy/disabled semantics and ignore repeat activation
until the operation settles, so async work cannot drop the keyboard ring.
@@ -2262,8 +2278,9 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged.
> to yield to: whoever handles ESC first calls `preventDefault()` **and**
> `stopImmediatePropagation()`, and the launcher skips `defaultPrevented` events.
> `stopPropagation()` alone does **not** stop later listeners on the same
> `document` — that is exactly how the compact Radio disclosure made one key
> close the disclosure _and_ dismiss the launcher.
> `document` — that is exactly how a disclosure that called it alone made one
> key close the disclosure _and_ dismiss the launcher. Cockpit's utility
> disclosures (Display, Radio) claim the key the immediate way.
>
> **Accepted:** a surface class that never clears means no launcher for that page
> load, with no timeout. None of the four classes is restored at startup, so an
@@ -2847,7 +2864,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
@@ -2899,7 +2916,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
@@ -2943,7 +2960,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> corridor). Its minimal Display popover exposes Intel HUD,
> Detection, Parameters, and 3D aircraft. During Cockpit, those existing
> standard Display controls move into the Cockpit popover and retain their normal
> nested interaction: HUD plus Tactical/Operator/Minimal layout, Detection plus
> nested interaction: HUD plus Tactical/Minimal/Cyber layout, Detection plus
> Density/Allocation/Fade/Outside tuning, and 3D plus Proximity/All mode. The
> same nodes and state return to the map Display on exit, so Cockpit does not
> maintain a second control state. **Detection is owned by the CONTACTS
@@ -3005,15 +3022,10 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> stacking. Narrow mobile Cockpit
> viewports suppress the legacy layer stack, peripheral HUD, and secondary
> Context/briefing panes so the flight instruments and primary controls remain
> unobstructed. Outside Cockpit, Radio starts collapsed while off. When Context
> is collapsed, its Radio header icon opens compact controls whose explicit
> Enable/Disable action owns power; stable close and full-panel buttons own
> compact dismissal and one-way detailed expansion independently. When Context
> is already expanded, that same icon skips the floating compact card, expands
> the embedded Radio section, scrolls it into view, and moves keyboard focus to
> its disclosure control. The detailed panel's normal accordion control owns
> its collapse. Compact/detailed state and playback continuity remain shared.
> Radio volume in the full, compact, and Cockpit surfaces, plus Space Mission
> unobstructed. Outside Cockpit, Radio starts collapsed while off and lives
> only in its section inside Context; the detailed panel's normal accordion
> control owns its disclosure and its Enable/Disable action owns power.
> Radio volume in the full and Cockpit surfaces, plus Space Mission
> replay speed, use Display / Sharpen's muted 3px rail, circular cyan thumb,
> glow, and mono value treatment. Their larger transparent hit areas, keyboard
> focus indication, ranges, disabled states, and mission speed scale remain
@@ -3798,7 +3810,7 @@ test:track` 43 tracking invariants · headless QA harnesses under
### Interface localization (October 2026)
Shipped locales: **en** (source and fallback), **es**, **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).
@@ -3809,7 +3821,7 @@ Shipped locales: **en** (source and fallback), **es**, **ru**, **zh-CN** and **z
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
@@ -4789,13 +4801,6 @@ synchronous playback-control observers run, so reentrant voice cleanup cannot
overwrite a released stream with a stale paused state. Layer
destruction likewise retains an enabled manager entry when module disable or
destroy fails semantically, preventing an active orphan and allowing retry.
When Context is collapsed, explicitly activating its Radio header icon reveals
the compact transport; hover and focus alone do not open it. When Context is
expanded, the icon instead expands and scrolls to the embedded Radio section.
Its accessible label, controlled region, and expanded state keep describing the
current route throughout enabling, enabled, disabling, and uncertain lifecycle
renders. Both routes preserve Radio power, playback, station, filter, and volume
state.
Bundled datasets live in `src/data/local_data/` with per-folder provenance READMEs; they are lazily loaded via `src/data/localGeojsonCore.js` (Vite `?url` assets) when toggled on.
@@ -4816,6 +4821,8 @@ silently demoting every later lookup for the session.
- The expanded `CONTEXT` view offers mutually exclusive `CONTACTS` and `SPACE MISSIONS` modes. Selecting `CONTACTS` enables the context-owned Flights, Military Flights, AIS Vessels, and Mapped Installations dependencies only when they are not already user-enabled; selecting `SPACE MISSIONS` enables the recent-launch layer and its Satellite dependency. `CONTACTS` cycles the nearest supported contact of whatever type is selected. Satellites are deliberately excluded from those Awareness cohorts and keep their own tracking UX. Selecting the active mode again returns to the neutral chooser and releases only mode-owned dependencies.
- If a civilian or military aircraft is already tracked when `CONTACTS` becomes operational, that source-owned track is adopted as the Context subject before nearest-contact autofocus. Context rechecks the tracker after its dependencies settle, so a newer selection wins, while an explicit clear during activation prevents fallback from silently selecting a replacement. Cockpit entry remains unavailable until that Context transaction has settled, so its camera takeover cannot clear Cesium tracking before adoption. Adoption does not recreate tracking or transfer camera ownership; it initializes the normal 250 km ring, history, proximity results, and Cockpit Previous/Next state for the original aircraft.
- `SEARCH NEARBY SITES` retains the bounded OSM results and makes one user-initiated, view-biased Google Maps Places text search for “military installation.” Google results are source-stamped, deduplicated against OSM by rounded location/name, and remain mapped context rather than operational claims. If Places is unavailable or the API is not enabled for the supplied key, OSM context remains available. A view across the antimeridian is searched around its own centre, with a radius from its real width.
- The Context header holds only its title and collapse control. It no longer carries a Radio shortcut or a compact Radio player: Radio is the `RADIO` section inside the panel, with its own disclosure and the single `ENABLE` power control. Cockpit keeps its own compact Radio utility.
- Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the expanded panel's section titles use `--text-secondary`. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art.
- The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset.
- Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`.
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
@@ -5188,8 +5195,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)
@@ -5607,7 +5614,7 @@ apply to `public/` assets or built-preview serving.
- The desktop right rail (`#right-context-rail`) owns `DISPLAY`, `CCTV`, its active parameter controls, and `GLOBAL CONTEXT` as one fixed responsive stack in that order. Its compact buttons use the same 176 px width as the left accordion and one consistent 50 px height, share the left stack's 52 px edge inset and measured top baseline across HUD variants, then constrain themselves against visible HUD/chrome rectangles and the remaining vertical corridor. `DISPLAY` is no longer draggable and legacy saved coordinates are ignored.
- The right rail is labeled **DISPLAY** (formerly "MOVE") and groups, in order, HUD, DETECT, Bloom, Sharpen, 3D, Clean-UI (HUD + DETECT promoted to the top). Its expanded controls retain the same compact 176 px width as the right-side tabs instead of growing to the wider Context detail-card width. It starts expanded on first run and respects the user's later `v6` collapse choice. Collapses/expands with directional chevrons (`◀` collapsed, `▶` expanded).
- Display and Context use matching 330 px expanded widths and matching compact tab dimensions. The parameter panel is part of Display's expanded content. DISPLAY may remain open beside one contextual panel; CCTV and Context are mutually exclusive. In Tactical HUD, expanding CCTV or Context hides the other contextual launcher while DISPLAY remains independently available. The most recently opened right-rail panel owns the constrained lane even when it appears later in DOM order; passive restoration and automatic disclosure do not replace that explicit owner. Minimal and other HUD layouts retain the collapsed launchers; when their active panel exceeds the measured corridor, the rail reserves sibling heights and gaps and scrolls the active panel internally.
- At 720px and below both panel stacks (`#left-panel-stack`, `#right-context-rail`) go edge-to-edge and scroll vertically. Every hosted panel's decorative `.panel-glow` is pinned to its panel box there (`inset: 0` in `src/ui/styles/responsive.css`): on desktop the glow overhangs the panel by 18–20 px as harmless paint, but inside a scroll container that overhang is scrollable overflow on both axes and drew a horizontal scrollbar band plus a vertical scrollbar that scrolled nothing but glow whenever CCTV, Context, Data Layers or Scenes was expanded. The glow's blur is ink overflow and never scrolls, so the look is unchanged. Do not fix this by clipping the stacks: `overflow-x: clip` would also clip the Context radio popover and would not remove the vertical scrollbar. Pinned by `src/ui/panelRails.test.mjs` against every `(max-width: 720px)` block in the flattened stylesheet.
- At 720px and below both panel stacks (`#left-panel-stack`, `#right-context-rail`) go edge-to-edge and scroll vertically. Every hosted panel's decorative `.panel-glow` is pinned to its panel box there (`inset: 0` in `src/ui/styles/responsive.css`): on desktop the glow overhangs the panel by 18–20 px as harmless paint, but inside a scroll container that overhang is scrollable overflow on both axes and drew a horizontal scrollbar band plus a vertical scrollbar that scrolled nothing but glow whenever CCTV, Context, Data Layers or Scenes was expanded. The glow's blur is ink overflow and never scrolls, so the look is unchanged. Do not fix this by clipping the stacks: `overflow-x: clip` would not remove the vertical scrollbar. Context draws no glow at all (`display: none`), so it is not part of that pin. Pinned by `src/ui/panelRails.test.mjs` against every `(max-width: 720px)` block in the flattened stylesheet.
- `STYLE PRESETS` and `LOCATIONS` start collapsed, expand on intentional hover/click, and auto-collapse after hover leave delay.
- Collapsed mini-status indicators show active style and active location/landmark.
- Detection mode is user-controlled and should persist when switching styles. Since 2026-08-22 it also STARTS on — Dense @ 75% for every style on a first run, Normal included — as a `GLOBAL_POST_DEFAULTS` baseline that does NOT set `_detectionUserOverridden`. Exception (unchanged): selecting a military style (CRT/NVG/FLIR) auto-enables the same Dense preset, but only until the user manually changes detection this session (`_detectionUserOverridden` gate), after which style switches never touch it.
@@ -5676,7 +5683,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
+36 -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 (retired), 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.
@@ -129,6 +129,9 @@ table learns its code. A new style's settings are encoded against its own
defaults, while a decoder that does not know the style uses Normal's; adding a
style also extends the pinned style-defaults table.
`hudVariant` code 1 (`operator`) is retired: the app no longer offers that
layout and restores it as `tactical`. The code stays reserved.
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
@@ -163,7 +166,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 +345,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`), **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), [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 / Minimal / Cyber | Tattico / 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 … |
+112
View File
@@ -0,0 +1,112 @@
# Japanese (`ja`)
Catalog: `src/i18n/locales/ja.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation source: abiyeenzo (#913).
## Style
- Sentences use です/ます: 「キーを保存しました」, 「もう一度お試しください」. Labels, buttons and HUD chips are short nouns or verb stems without a final 。: 「再試行」, 「保存」, 「ライブ」, 「オフ」.
- Japanese has no case: an UPPERCASE English label becomes the same short Japanese label. Codes, units and names stay in their own case (HDG, ADS-B, km).
- Full-width punctuation in Japanese text: 、。:?!()「」. Quote names and titles with 「」: 「{shot}」を削除しますか? Keep `·` as the HUD separator, `…` for an ellipsis and `—`, `→`, `↗` as in English. A dash inside a sentence becomes 。 or :.
- Button names cited in running text go in []: [次へ]を使ってください.
- No space between Japanese and Latin letters, digits or a `{placeholder}`: 「AIS船舶」, 「Spaceを長押しで話す」, 「{count}件」, 「{name}を表示」.
- Two exceptions keep the space: a Japanese label before its value in a readout (「雲量 {pct}%」, 「ステーション {id}」, 「高度 {altitude}」), and the state words in toggles (「CCTV オン」, 「ラジオ オフ」).
- Waiting states are 「X待ち」, not 「Xを待機中」: 「観測データ待ち」, 「進路/予報円は第{number}報待ち」, 「RTL-SDRデバイスの接続待ち…」. 待機中 is for something on standby with no object: 「フィード待機中」, 「音声待機中」.
- Key names keep their own case in running text and hints: Tab, Enter, Space, Esc (「Tabでプレビュー · Enter / Spaceで選択」). Text that CSS upper-cases shows them in capitals, as in English.
- Loanwords are katakana with the long-vowel mark, as in Microsoft and Apple style: ユーザー, ブラウザー, ビューアー, プロバイダー, レイヤー, フィルター, モニター, パラメーター. Pick one katakana form per term and keep it (see the glossary).
- Product, provider and dataset names stay in Latin script: Google Maps, Cesium ion, OpenSky, CelesTrak, NASA FIRMS, Sentinel-2, KartaView. Google's own product names use Google's Japanese, joined like any Latin word: Googleストリートビュー. Nepal place names in the Bhote Koshi scenes stay in Latin script, as in zh-CN; country and well-known city names are translated (ネパール, 東京, オースティン).
## Numbers, units and dates
- Units: km, m, km/h, m/s, kt, ft, mph, hPa, MW, dB, °, % stay as written, with the English spacing (「250 km」, 「{frp} MW」, 「95 kt」). Code-built cockpit distances follow this too (`cockpitUnitCase` skips the HUD upper-casing for ja, as for zh). The cockpit's own speed caption is the HUD code KT, in 対地速度 · KT and under the readout.
- Number ranges keep the English en dash, not ~: 「2–6日ごと」, 「50–99%」, 「南緯25°–北緯80°」.
- Time units are words written straight after the number: 秒, 分, 時間, 日 (「5分前」, 「{count}時間」, 「1時間5分」). `formatQuantity` and `formatRelativeTime` drop the space for Japanese unit words, so code-built values match the catalog.
- Counters follow the thing counted: 機 (aircraft), 隻 (ships), 基 (satellites), 台 (cameras, vehicles), 局 (stations), 件 (records, results, events), か所 (sites), 個 (generic). Prefer a counter to a bare number.
- Dates come from `Intl` and read year first: 2026年8月26日. Coordinates in prose are 北緯 / 東経; HUD hemisphere letters stay N, S, E, W.
## Plurals
`Intl.PluralRules('ja')` has one category, `other`: a `tn()` entry has only that form. Put the counter next to the number: 「航空機{n}機」, 「船舶{n}隻」, 「メッセージ {count}件」.
## Glossary
| English | 日本語 |
| ----------------------------------------- | --------------------------------------- |
| layer / data layer | レイヤー / データレイヤー |
| feed | フィード |
| source / data source / attribution | ソース / データソース / 出典 |
| LIVE (status) / live data | ライブ / ライブデータ |
| SIMULATED | シミュレーション |
| stale / degraded / unavailable | 更新遅延 / 性能低下 / 利用不可 |
| cached (status) | キャッシュの(キャッシュの予報) |
| PARTIAL (feed state) | 部分的 |
| upstream (remote data source) | 配信元 |
| retry | 再試行 |
| key (API key) / POWER UP | キー(APIキー)/ パワーアップ |
| Provider Settings | プロバイダー設定 |
| globe / map / map source / basemap | 地球儀 / 地図 / マップソース / ベースマップ |
| mapped (OSM-mapped sites) | マップ済み |
| aircraft / flight / military flights | 航空機 / フライト / 軍用機 |
| FLIGHT {count} (a rocket stage's nth flight) | {count}回目の飛行 |
| vessel, ship | 船舶 |
| satellite | 衛星 |
| launch (rocket) / space mission | 打ち上げ / 宇宙ミッション |
| stage (rocket) / downrange | 段 / ダウンレンジ |
| contact (tracked object) | コンタクト |
| installation, site | 施設 |
| camera / CCTV camera | カメラ / CCTVカメラ |
| earthquake / active fire / fire perimeter | 地震 / 火災検知 / 延焼範囲 |
| contained / containment (fire) | 封じ込め / 封じ込め率 |
| scene / shot | シーン / ショット |
| briefing / panel / dock | ブリーフィング / パネル / ドック |
| preset / visual preset | プリセット / ビジュアルプリセット |
| Tactical / Minimal / Cyber | タクティカル / ミニマル / サイバー |
| Scope (circular viewport mask) / keyhole | スコープ / キーホール |
| Clean UI | クリーンUI |
| cockpit | コックピット |
| heading / altitude / ground speed | 機首方位 / 高度 / 対地速度 |
| track (TRK, direction over the ground) | 対地針路 |
| HDG / BRG / CRS / FOV / SPD / POS | そのまま(HUDコード) |
| squawk | スコーク |
| radio failure / emergency (7600 / 7700) | 通信機故障 / 緊急事態 |
| map heading / North up | 方位 / 北を上に |
| Street Level / Street View (Google) | ストリートレベル / ストリートビュー |
| Recent Imagery | 最近の衛星画像 |
| Global Context / Context (panel) | グローバルコンテキスト / コンテキスト |
| radio (station) / tune / seek | ラジオ(局)/ 選局・同調 / シーク |
| Public Safety / SAFETY (radio category) | 警察・消防 |
| transit / bikeshare | 公共交通 / シェアサイクル |
| occupancy: empty / standing room only / crushed | 乗客なし / 立ち乗りのみ / 非常に混雑 |
| traffic flow (TomTom) | 交通流 |
| Previous / Next | 前へ / 次へ |
| SIG / NEWS / LOCAL (briefing tabs) | シグナル / ニュース / ローカル |
| compass points (N, NNE, NE, ENE, …) | 北, 北北東, 北東, 東北東, … |
| weather field / FIELD (selector) | 要素 |
| weather model / forecast lead hours | モデル / 予報時間 |
| valid (forecast time) / issued | 対象時刻 / 発表 |
| radar reflectivity / lightning density | レーダー反射強度 / 雷密度 |
| cyclone, storm (NHC/CPHC) / advisory | サイクロン / 情報(第N報); alone: サイクロン情報 |
| potential tropical cyclone (NHC PC) | サイクロン化のおそれがある擾乱 |
| forecast uncertainty cone | 予報円 |
| tropical storm / tropical depression | 熱帯暴風雨 / 熱帯低気圧 |
| scene cloud / mosaic / daily overview | シーン雲量 / モザイク / 日次概観 |
| box (Recent Imagery area) | 範囲 |
| WINDOW (search radius around a subject) | 範囲 |
| coverage / coverage % / full, partial | カバー範囲 / カバー率(%のみ)/ 全体, 一部 |
| pin (Recent Imagery) | 固定 |
| clear day (Recent Imagery) | 雲の少ない日 |
| outline / mark (map annotation) | 輪郭 / マーク |
| share (a view or link) | 共有 |
| import / export (project, presets) | インポート / エクスポート |
| EXPORT (image, PNG) | 書き出し |
| voice (the voice assistant) | 音声アシスタント / 音声機能 |
| activate (a control, screen-reader text) | 押す(「押すと…」) |
| AI agent | AIエージェント |
| frame (bring into view) | 視野に収める |
| story beat / evidence beat | ストーリー区切り / 証拠の区切り |
| camera pose / camera move | カメラの姿勢 / 移動 |
| roundabout / ramp / exit | ロータリー / ランプ / 出口 |
| reduced motion | 動きを減らす |
| NOT AN ALL-CLEAR | 安全の保証ではありません |
| … Watch (scene titles: Earth, Orbital) | 監視(地球監視, 軌道監視) |
+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 -1
View File
@@ -43,7 +43,7 @@ Translation sources: abiyeenzo (#914) and EDIT-Aeterna (#979).
| briefing / panel / dock | 简报 / 面板 / 停靠栏 |
| preset / visual preset | 预设 / 视觉预设 |
| Display / HUD layout | 显示 / HUD 布局 |
| Tactical / Operator / Minimal / Cyber | 战术 / 操作员 / 精简 / 赛博 |
| Tactical / Minimal / Cyber | 战术 / 精简 / 赛博 |
| Scope (circular viewport mask) / keyhole | 视域 |
| Clean UI | 纯净界面 |
| cockpit | 座舱 |
-2
View File
@@ -214,7 +214,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",
@@ -705,7 +704,6 @@
"src/ui/shareRestoration.js",
"src/ui/visualSettings.js",
"src/ui/panelChrome.js",
"src/ui/panelChrome.radioOwnership.test.mjs",
"src/testSupport/readShellSource.mjs",
"src/ui/stateOwners.test.mjs",
"src/layers/flights/records.js",
+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,
)) {
+272 -12
View File
@@ -439,8 +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"
],
@@ -463,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"]
@@ -479,8 +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"
],
@@ -509,8 +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",
@@ -531,8 +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"
],
@@ -548,8 +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"
],
@@ -568,8 +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",
@@ -595,8 +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"
@@ -630,8 +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"
@@ -647,8 +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"
@@ -697,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",
@@ -806,8 +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",
@@ -815,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"]
},
@@ -928,11 +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"]
},
@@ -996,8 +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"
@@ -1045,8 +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"
],
@@ -1108,8 +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"
],
@@ -1138,8 +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",
@@ -1169,8 +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"
],
@@ -1200,8 +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"
@@ -1234,8 +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"
],
@@ -1277,8 +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"
@@ -1314,8 +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"
@@ -1365,8 +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"
],
@@ -1395,8 +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"
],
@@ -1444,8 +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"
],
@@ -1508,8 +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"
@@ -1538,8 +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"
],
@@ -1579,8 +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"
],
@@ -1602,8 +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"
],
@@ -1659,8 +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"
],
@@ -1722,8 +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",
@@ -2240,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",
@@ -2607,8 +2780,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",
@@ -2631,7 +2810,8 @@
"src/voice/geminiCaptureWorklet.js",
"src/ui/statusBadge.js",
"src/sceneCommandRelease.js",
"src/scopeMaskPolicy.js"
"src/scopeMaskPolicy.js",
"src/visualStylePolicy.js"
],
"external": [
"hls.js",
@@ -3192,8 +3372,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",
@@ -3303,8 +3489,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"
@@ -3327,8 +3519,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"
],
@@ -3348,7 +3546,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",
@@ -3364,12 +3561,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"]
},
@@ -3391,8 +3595,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"
],
@@ -3483,8 +3693,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"
@@ -3539,8 +3755,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",
@@ -3794,8 +4016,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"
],
@@ -3838,8 +4066,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"
],
@@ -3892,8 +4126,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"
],
@@ -3909,8 +4149,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",
@@ -3938,7 +4184,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",
@@ -3947,7 +4192,8 @@
"src/data/idSwitches.js",
"src/data/detectionPolicy.js",
"src/scopeMaskPolicy.js",
"src/i18n/marker.js"
"src/i18n/marker.js",
"src/visualStylePolicy.js"
],
"external": []
},
@@ -3969,8 +4215,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",
@@ -4051,7 +4303,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"]
},
@@ -4152,7 +4405,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": []
},
@@ -4166,8 +4420,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",
+19 -19
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(
@@ -1751,7 +1751,7 @@ try {
return state;
}, { variantName: variant, kind: openKind });
for (const variant of ['off', 'minimal', 'tactical', 'operator']) {
for (const variant of ['off', 'minimal', 'tactical']) {
for (const kind of ['display', 'radio']) {
const state = await desktopState(variant, kind);
check(
@@ -2081,7 +2081,7 @@ try {
await page.screenshot({ path: path.join(shotsDir, `cyber-resize-${height}.png`) });
}
}
await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('operator'));
await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('tactical'));
const signalTransition = await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
@@ -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),
+5 -5
View File
@@ -175,12 +175,12 @@ const MUTATIONS = [
to: ' /* belt removed */',
},
{
// Anchored past the call so it cannot land on the cockpit disclosure's own
// stopImmediatePropagation() a few lines below and prove nothing about this.
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
// Anchored past the call so it lands on the Cockpit utility disclosure's
// Escape claim and nowhere else.
defect: 'a Cockpit utility disclosure returns to stopPropagation, so later document listeners also act on its ESC',
file: 'radio',
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
to: ' event.stopPropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
from: " event.stopImmediatePropagation();\n const kind = displayOpen ? 'display' : 'radio';",
to: " event.stopPropagation();\n const kind = displayOpen ? 'display' : 'radio';",
},
{
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
-47
View File
@@ -357,53 +357,6 @@ async function runArbitrationSection(page, { shots, consoleErrors }) {
`classVisible=${afterUncoveredEsc.classVisible} session=${afterUncoveredEsc.session}`,
);
// ── Blocker repro: a control that claims only the KEY ───────────────────
// The compact Radio disclosure closes on ESC from a capture listener bound
// long before this module exists. It called stopPropagation(), which does
// NOT stop later listeners on the same document — so one key closed the
// disclosure AND dismissed the launcher. The card is not hiding behind this
// one and must not yield to it: the key is simply already spoken for.
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
const beforeRadio = await launcherState();
record('the launcher is up before the radio disclosure opens', beforeRadio.onScreen);
const radioOpened = await page.evaluate(() => {
document.getElementById('context-radio-toggle-btn')?.click();
return !!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open');
});
await sleep(250);
record('the compact Radio disclosure opens', radioOpened);
const withRadio = await launcherState();
record(
'a small disclosure is a key contest, not a screen takeover — no yield',
withRadio.classVisible && withRadio.topmost,
`classVisible=${withRadio.classVisible} topmost=${withRadio.topmost}`,
);
await page.keyboard.press('Escape');
await sleep(400);
const radioAfterEsc = await page.evaluate(() => (
!!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')
));
const launcherAfterRadioEsc = await launcherState();
record('ESC closes the disclosure', radioOpened && !radioAfterEsc,
`disclosure still open=${radioAfterEsc}`);
record(
'that SAME ESC does not also dismiss the launcher',
beforeRadio.onScreen && launcherAfterRadioEsc.classVisible
&& launcherAfterRadioEsc.session !== 'dismissed',
`classVisible=${launcherAfterRadioEsc.classVisible} session=${launcherAfterRadioEsc.session} — one key, one action`,
);
// ...and the NEXT press is the launcher's, so nothing was permanently taken.
await page.keyboard.press('Escape');
await sleep(600);
const escAfterRadioClosed = await launcherState();
record(
'the next ESC belongs to the launcher again',
beforeRadio.onScreen && !escAfterRadioClosed.classVisible
&& escAfterRadioClosed.session === 'dismissed',
`classVisible=${escAfterRadioClosed.classVisible} session=${escAfterRadioClosed.session}`,
);
// ── Deferred reveal: a surface already up when the launcher would show ──
// The class goes on as soon as the app exists, which is well before the
// launcher's reveal (~T+1.9s), so init genuinely sees a screen it does not
+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
+76 -330
View File
@@ -81,6 +81,32 @@ function check(name, ok, detail = '') {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Radio lives only inside Context; a user reaches its controls by expanding
// Context and then Radio. Returns the prior collapse state so a step can put
// the panels back for the scenarios that follow.
async function revealRadioControls(page, targetId) {
const prior = await page.evaluate(async (id) => {
const manager = window.__godsEyeView.styleManager;
const collapsed = (panelId) => document.getElementById(panelId).classList.contains('collapsed');
const state = { context: collapsed('global-context-panel'), radio: collapsed('radio-panel') };
manager.setPanelCollapsed('global-context-panel', false, { explicit: true });
manager.setPanelCollapsed('radio-panel', false, { explicit: true });
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
document.getElementById(id).scrollIntoView({ block: 'nearest' });
return state;
}, targetId);
await page.waitForSelector(`#${targetId}`, { visible: true });
return prior;
}
async function restoreRadioPanels(page, prior) {
await page.evaluate(({ context, radio }) => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('radio-panel', radio);
manager.setPanelCollapsed('global-context-panel', context);
}, prior);
}
// Native pick buffers and clustered overlay entries settle on rendered frames,
// not on wall-clock sleeps, particularly with software rendering.
async function settleRadioFrames(page) {
@@ -285,31 +311,16 @@ async function main() {
window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true);
});
const enablePanels = await revealRadioControls(page, 'radio-enable-btn');
const enableStartedAt = Date.now();
const initialDisclosure = await page.evaluate(() => {
const button = document.getElementById('context-radio-toggle-btn');
const mini = document.getElementById('context-radio-mini');
const enabledBefore = window.__godsEyeView.dataManager.isEnabled('radio');
button.click();
return {
enabledBefore,
enabledAfterDisclosure: window.__godsEyeView.dataManager.isEnabled('radio'),
expanded: button.getAttribute('aria-expanded'),
miniHidden: mini.hidden,
};
});
check(
'Radio launcher discloses compact controls without changing power',
!initialDisclosure.enabledBefore && !initialDisclosure.enabledAfterDisclosure
&& initialDisclosure.expanded === 'true' && !initialDisclosure.miniHidden,
JSON.stringify(initialDisclosure),
);
await page.$eval('#context-radio-mini-enable-btn', (button) => button.click());
await page.click('#radio-enable-btn');
await page.waitForFunction(() => {
const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
return state.enabled && !state.loading && state.stationCount === 750;
});
const enableElapsedMs = Date.now() - enableStartedAt;
// Later scenarios start from the collapsed rail this step began with.
await restoreRadioPanels(page, enablePanels);
const initial = await page.evaluate(() => {
const module = window.__godsEyeView.dataManager.layers.get('radio').module;
const state = module.getUIState();
@@ -328,9 +339,6 @@ async function main() {
color: entry.dataset.radioColor,
})),
enablePressed: document.getElementById('radio-enable-btn').getAttribute('aria-pressed'),
launcherExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'),
launcherActive: document.getElementById('context-radio-toggle-btn').classList.contains('active'),
compactEnablePressed: document.getElementById('context-radio-mini-enable-btn').getAttribute('aria-pressed'),
fullPlayEnabled: !document.getElementById('radio-play-btn').disabled,
fullPlayLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'),
tunerVisible: !document.getElementById('radio-tuner').hidden,
@@ -341,9 +349,8 @@ async function main() {
check('750-station catalog renders within the browser budget', initial.state.stationCount === 750 && enableElapsedMs < 5000, `${enableElapsedMs}ms`);
check('enable does not autoplay', initial.plays === 0 && initial.state.audioState === 'stopped');
check(
'compact and detailed Radio power controls reflect the layer manager independently of disclosure',
initial.enablePressed === 'true' && initial.compactEnablePressed === 'true'
&& initial.launcherExpanded === 'true' && initial.launcherActive,
'Radio power control reflects the layer manager after the panels collapse again',
initial.enablePressed === 'true',
);
check('dynamic canonical and genre categories reach the UI', initial.filterOptions >= 10, `${initial.filterOptions} options`);
const musicOption = initial.filterPalette.find((entry) => entry.id === 'music');
@@ -419,14 +426,10 @@ async function main() {
prevDisabled: document.getElementById('radio-prev-btn').disabled,
playDisabled: document.getElementById('radio-play-btn').disabled,
volumeDisabled: document.getElementById('radio-volume').disabled,
disclosureLabel: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'),
compactPowerLabel: document.getElementById('context-radio-mini-enable-btn').getAttribute('aria-label'),
compactPowerText: document.getElementById('context-radio-mini-enable-btn').textContent,
fullLabel: document.getElementById('radio-enable-btn').getAttribute('aria-label'),
fullText: document.getElementById('radio-enable-btn').textContent,
layerState: document.getElementById('radio-layer-state').textContent,
playbackMessage: document.getElementById('radio-playback-state').textContent,
miniStation: document.getElementById('context-radio-mini-station').textContent,
dataRailText: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.textContent,
dataRailState: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.dataset.feedState,
dataRailLabel: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.getAttribute('aria-label'),
@@ -468,14 +471,10 @@ async function main() {
&& uncertainPresentation.prevDisabled
&& uncertainPresentation.playDisabled
&& uncertainPresentation.volumeDisabled
&& /compact Radio controls/i.test(uncertainPresentation.disclosureLabel)
&& /uncertain/i.test(uncertainPresentation.compactPowerLabel)
&& uncertainPresentation.compactPowerText === 'RECONCILE'
&& /uncertain/i.test(uncertainPresentation.fullLabel)
&& uncertainPresentation.fullText === 'RECONCILE'
&& uncertainPresentation.layerState === 'UNCERTAIN'
&& /uncertain/i.test(uncertainPresentation.playbackMessage)
&& uncertainPresentation.miniStation === 'RADIO STATE UNCERTAIN'
&& uncertainPresentation.dataRailText === 'UNCERTAIN'
&& uncertainPresentation.dataRailState === 'uncertain'
&& /uncertain/i.test(uncertainPresentation.dataRailLabel)
@@ -517,10 +516,6 @@ async function main() {
presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive,
markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show,
full: document.getElementById('radio-enable-btn').textContent,
disclosure: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'),
disclosureControls: document.getElementById('context-radio-toggle-btn').getAttribute('aria-controls'),
disclosureExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'),
compactPower: document.getElementById('context-radio-mini-enable-btn').textContent,
layerState: document.getElementById('radio-layer-state').textContent,
message: document.getElementById('radio-playback-state').textContent,
stopDisabled: document.getElementById('radio-stop-btn').disabled,
@@ -534,8 +529,6 @@ async function main() {
&& disablingPresentation.presentationActive === false
&& disablingPresentation.markerSourceVisible === false
&& disablingPresentation.full === 'DISABLING'
&& /compact Radio controls/i.test(disablingPresentation.disclosure)
&& disablingPresentation.compactPower === 'DISABLING'
&& disablingPresentation.layerState === 'DISABLING'
&& /disabling/i.test(disablingPresentation.message)
&& disablingPresentation.stopDisabled
@@ -592,10 +585,6 @@ async function main() {
presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive,
markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show,
full: document.getElementById('radio-enable-btn').textContent,
disclosure: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'),
disclosureControls: document.getElementById('context-radio-toggle-btn').getAttribute('aria-controls'),
disclosureExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'),
compactPower: document.getElementById('context-radio-mini-enable-btn').textContent,
layerState: document.getElementById('radio-layer-state').textContent,
message: document.getElementById('radio-playback-state').textContent,
tunerHidden: document.getElementById('radio-tuner').hidden,
@@ -610,10 +599,6 @@ async function main() {
&& enablingPresentation.presentationActive === false
&& enablingPresentation.markerSourceVisible === false
&& enablingPresentation.full === 'ENABLING'
&& enablingPresentation.disclosure === 'Go to expanded Radio section'
&& enablingPresentation.disclosureControls === 'radio-panel'
&& enablingPresentation.disclosureExpanded === 'true'
&& enablingPresentation.compactPower === 'ENABLING'
&& enablingPresentation.layerState === 'ENABLING'
&& /enabling/i.test(enablingPresentation.message)
&& enablingPresentation.tunerHidden
@@ -1581,112 +1566,9 @@ async function main() {
JSON.stringify(directoryRefreshContinuity),
);
await page.select('#radio-filter', 'news');
check('launcher enables Radio without forcing Context open', !initial.panelExpanded && !initial.contextExpanded && initial.nestedInContext);
check('enabled Radio stays nested in Context and leaves both panels collapsed', !initial.panelExpanded && !initial.contextExpanded && initial.nestedInContext);
check('full Play is available before a station is selected', !initial.state.selected && initial.fullPlayEnabled && initial.fullPlayLabel === 'Play nearest radio station');
const hoverOnlyDisclosure = await page.evaluate(() => {
const button = document.getElementById('context-radio-toggle-btn');
if (button.getAttribute('aria-expanded') === 'true') button.click();
return {
expanded: button.getAttribute('aria-expanded'),
hidden: document.getElementById('context-radio-mini').hidden,
};
});
await page.hover('#context-radio-toggle-btn');
await sleep(100);
const hoverOnlyVisible = await page.$eval('#context-radio-mini', (mini) => (
!mini.hidden && getComputedStyle(mini).display !== 'none'
));
check(
'hover alone does not disclose compact Radio controls',
hoverOnlyDisclosure.expanded === 'false' && hoverOnlyDisclosure.hidden && !hoverOnlyVisible,
JSON.stringify({ hoverOnlyDisclosure, hoverOnlyVisible }),
);
await page.click('#context-radio-toggle-btn');
await page.waitForFunction(() => {
const button = document.getElementById('context-radio-toggle-btn');
const mini = document.getElementById('context-radio-mini');
return button.getAttribute('aria-expanded') === 'true'
&& !mini.hidden && getComputedStyle(mini).display !== 'none';
});
const compact = await page.evaluate(() => {
const mini = document.getElementById('context-radio-mini');
const toggle = document.getElementById('context-radio-toggle-btn');
const dock = document.getElementById('context-radio-dock');
const miniRect = mini.getBoundingClientRect();
const toggleRect = toggle.getBoundingClientRect();
const miniStyle = getComputedStyle(mini);
return {
visible: miniStyle.display !== 'none' && miniRect.width > 0 && miniRect.height > 0,
display: miniStyle.display,
visibility: miniStyle.visibility,
opacity: miniStyle.opacity,
miniRect: { top: miniRect.top, left: miniRect.left, width: miniRect.width, height: miniRect.height },
toggleRect: { top: toggleRect.top, left: toggleRect.left, width: toggleRect.width, height: toggleRect.height },
dockRect: (() => {
const rect = dock.getBoundingClientRect();
return { top: rect.top, left: rect.left, width: rect.width, height: rect.height };
})(),
offsetParent: mini.offsetParent?.id || mini.offsetParent?.className || null,
prevEnabled: !document.getElementById('context-radio-mini-prev-btn').disabled,
playEnabled: !document.getElementById('context-radio-mini-play-btn').disabled,
playLabel: document.getElementById('context-radio-mini-play-btn').getAttribute('aria-label'),
nextEnabled: !document.getElementById('context-radio-mini-next-btn').disabled,
volumeEnabled: !document.getElementById('context-radio-mini-volume').disabled,
offsetBelowToggle: Math.round(miniRect.top - toggleRect.bottom),
};
});
check(
'explicit launcher disclosure reveals enabled compact Radio controls',
compact.visible && compact.prevEnabled && compact.playEnabled && compact.nextEnabled && compact.volumeEnabled,
JSON.stringify(compact),
);
check('compact Play is available before a station is selected', compact.playLabel === 'Play nearest radio station');
check('compact Radio controls sit 20px below the launcher', compact.offsetBelowToggle === 20, `${compact.offsetBelowToggle}px`);
const compactCloseDisclosure = await page.evaluate(() => {
const launcher = document.getElementById('context-radio-toggle-btn');
const close = document.getElementById('context-radio-mini-close-btn');
close.focus();
close.click();
const result = {
launcherExpanded: launcher.getAttribute('aria-expanded'),
compactHidden: document.getElementById('context-radio-mini').hidden,
focusRestored: document.activeElement === launcher,
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
};
launcher.click();
return result;
});
check(
'dedicated compact close hides only the compact Radio disclosure and restores launcher focus',
compactCloseDisclosure.launcherExpanded === 'false'
&& compactCloseDisclosure.compactHidden
&& compactCloseDisclosure.focusRestored
&& compactCloseDisclosure.radioCollapsed,
JSON.stringify(compactCloseDisclosure),
);
const compactTouchDisclosure = await page.evaluate(() => {
const button = document.getElementById('context-radio-toggle-btn');
button.click();
const closed = button.getAttribute('aria-expanded') === 'false'
&& document.getElementById('context-radio-mini').hidden;
button.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true,
pointerType: 'touch',
isPrimary: true,
}));
button.click();
return {
closed,
reopened: button.getAttribute('aria-expanded') === 'true'
&& !document.getElementById('context-radio-mini').hidden,
};
});
check(
'touch/coarse-pointer activation can close and reopen compact Radio controls',
compactTouchDisclosure.closed && compactTouchDisclosure.reopened,
JSON.stringify(compactTouchDisclosure),
);
const multiExpandedPanelLayout = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const ids = ['data-panel', 'scene-panel', 'pp-toggles', 'cctv-panel', 'global-context-panel'];
@@ -2062,7 +1944,6 @@ async function main() {
hudVariant: manager.hud.getVariant(),
displayExpanded: display.getAttribute('aria-expanded') === 'true',
radioExpanded: radio.getAttribute('aria-expanded') === 'true',
contextRadioExpanded: document.getElementById('context-radio-toggle-btn')?.getAttribute('aria-expanded') === 'true',
focusId: document.activeElement?.id || null,
detectionMode: manager.getDetectionState().detectionMode,
models3dEnabled: manager._models3dEnabled,
@@ -2158,11 +2039,13 @@ async function main() {
for (let index = 0; index < 5; index += 1) {
recordLayoutStep();
if (index < 4) {
if (index < 2) {
hudLayout.value = index === 0 ? 'operator' : 'minimal';
hudLayout.dispatchEvent(new Event('change', { bubbles: true }));
} else {
// Off and back on in Minimal, so the toggle must restore a
// non-default layout before the cycle returns to Tactical.
if (index === 1 || index === 2) {
hudToggle.click();
} else {
hudLayout.value = index === 0 ? 'minimal' : 'tactical';
hudLayout.dispatchEvent(new Event('change', { bubbles: true }));
}
await waitForHudSettle();
}
@@ -2287,8 +2170,7 @@ async function main() {
document.getElementById('cockpit-radio-next-btn'),
].every((button) => button?.isConnected && !button.disabled);
const detailsExcluded = !document.querySelector(
'#cockpit-radio-panel #context-radio-details-btn, '
+ '#cockpit-radio-panel #radio-filter, #cockpit-radio-panel #radio-tuner',
'#cockpit-radio-panel #radio-filter, #cockpit-radio-panel #radio-tuner',
);
const scenesHidden = getComputedStyle(scene).display === 'none';
radio.click();
@@ -2331,7 +2213,7 @@ async function main() {
scenesHidden,
mapProviderUtilityStable,
hudInteractionsExact: layoutSteps.map((step) => step.hud).join('>')
=== 'TACTICAL>OPERATOR>MINIMAL>OFF>MINIMAL',
=== 'TACTICAL>MINIMAL>OFF>MINIMAL>TACTICAL',
// The accordion clears the topline, Cockpit cards, and Cesium credits
// across every HUD layout. Its list owns any overflow internally.
leftLaneClearAcrossHudCycle: layoutSteps.every((step) => (
@@ -2381,7 +2263,6 @@ async function main() {
manager.setPanelCollapsed('global-context-panel', prior.contextCollapsed);
if (display.getAttribute('aria-expanded') !== String(prior.displayExpanded)) display.click();
if (radio.getAttribute('aria-expanded') !== String(prior.radioExpanded)) radio.click();
manager._setRadioDisclosure(prior.contextRadioExpanded);
hud.hidden = prior.hudHidden;
signal.hidden = prior.signalHidden;
if (prior.utilityTop) hud.style.setProperty('--cockpit-utility-top', prior.utilityTop);
@@ -2416,7 +2297,6 @@ async function main() {
&& document.getElementById('global-context-panel').classList.contains('collapsed') === prior.contextCollapsed
&& display.getAttribute('aria-expanded') === String(prior.displayExpanded)
&& radio.getAttribute('aria-expanded') === String(prior.radioExpanded)
&& document.getElementById('context-radio-toggle-btn')?.getAttribute('aria-expanded') === String(prior.contextRadioExpanded)
&& hud.hidden === prior.hudHidden
&& signal.hidden === prior.signalHidden
&& intelHud.style.getPropertyValue('transition') === priorHudTransition
@@ -2472,21 +2352,20 @@ async function main() {
check('first Play scenario starts without a leaked Radio selection', firstPlayPrecondition === null,
`selected=${firstPlayPrecondition}`);
await page.select('#radio-filter', 'all');
await page.$eval('#context-radio-toggle-btn', (button) => {
if (button.getAttribute('aria-expanded') !== 'true') button.click();
});
await page.evaluate(() => {
const camera = window.__godsEyeView.viewer.camera;
window.__qaFirstPlayFlyToCalls = [];
window.__qaFirstPlayOriginalFlyTo = camera.flyTo;
camera.flyTo = (options) => window.__qaFirstPlayFlyToCalls.push(options);
document.getElementById('context-radio-toggle-btn').focus();
});
await page.$eval('#context-radio-mini-play-btn', (button) => button.click());
const firstPlayPanels = await revealRadioControls(page, 'radio-play-btn');
await page.focus('#radio-play-btn');
await page.click('#radio-play-btn');
await page.waitForFunction(() => {
const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
return state.audioState === 'playing' || state.audioState === 'error';
});
await restoreRadioPanels(page, firstPlayPanels);
const postMicroDragPlayback = await page.evaluate(async () => {
const mod = window.__godsEyeView.dataManager.layers.get('radio').module;
let state = mod.getUIState();
@@ -2593,59 +2472,19 @@ async function main() {
);
await page.select('#radio-filter', 'news');
await page.$eval('#context-radio-mini-volume', (input) => {
await page.$eval('#radio-volume', (input) => {
input.value = '35';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
const compactVolume = await page.evaluate(() => ({
const radioVolume = await page.evaluate(() => ({
volume: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().volume,
full: document.getElementById('radio-volume').value,
mini: document.getElementById('context-radio-mini-volume').value,
label: document.getElementById('radio-volume-value').textContent,
}));
check('compact volume shares the full Radio player state', Math.abs(compactVolume.volume - 0.35) < 0.001 && compactVolume.full === '35' && compactVolume.mini === '35');
const compactDisclosureContinuity = await page.evaluate(async () => {
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('global-context-panel', true);
manager._setRadioDisclosure(true);
await radio.play({ origin: 'user' });
const before = radio.getUIState();
const disclosure = document.getElementById('context-radio-toggle-btn');
if (disclosure.getAttribute('aria-expanded') !== 'true') disclosure.click();
disclosure.click();
const closed = {
expanded: disclosure.getAttribute('aria-expanded'),
hidden: document.getElementById('context-radio-mini').hidden,
state: radio.getUIState(),
};
disclosure.click();
return {
before,
closed,
reopened: disclosure.getAttribute('aria-expanded'),
miniHidden: document.getElementById('context-radio-mini').hidden,
after: radio.getUIState(),
};
});
check(
'compact disclosure preserves playback, station, filter, and volume continuity',
compactDisclosureContinuity.closed.expanded === 'false'
&& compactDisclosureContinuity.closed.hidden
&& compactDisclosureContinuity.reopened === 'true'
&& !compactDisclosureContinuity.miniHidden
&& compactDisclosureContinuity.before.audioState === 'playing'
&& compactDisclosureContinuity.closed.state.audioState === 'playing'
&& compactDisclosureContinuity.after.audioState === 'playing'
&& compactDisclosureContinuity.after.selected?.id === compactDisclosureContinuity.before.selected?.id
&& compactDisclosureContinuity.after.filter === compactDisclosureContinuity.before.filter
&& compactDisclosureContinuity.after.volume === compactDisclosureContinuity.before.volume,
JSON.stringify({
before: compactDisclosureContinuity.before,
closed: compactDisclosureContinuity.closed,
reopened: compactDisclosureContinuity.reopened,
after: compactDisclosureContinuity.after,
}),
'full Radio volume drives the Radio player state',
Math.abs(radioVolume.volume - 0.35) < 0.001 && radioVolume.full === '35' && radioVolume.label === '35%',
JSON.stringify(radioVolume),
);
const voicePause = await page.evaluate(async () => {
@@ -2709,11 +2548,13 @@ async function main() {
idleVoice.audioState === 'paused',
idleVoice.audioState,
);
const manualPlayTarget = await page.evaluate(() => {
const manualPlayTarget = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('global-context-panel', true);
manager._setRadioDisclosure(true);
const button = document.getElementById('context-radio-mini-play-btn');
manager.setPanelCollapsed('global-context-panel', false);
manager.setPanelCollapsed('radio-panel', false);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const button = document.getElementById('radio-play-btn');
button.scrollIntoView({ block: 'nearest' });
const rect = button.getBoundingClientRect();
const style = getComputedStyle(button);
return {
@@ -2728,12 +2569,12 @@ async function main() {
};
});
check(
'manual voice takeover targets the visible compact Radio Play control',
'manual voice takeover targets the visible full Radio Play control',
manualPlayTarget.visible && manualPlayTarget.enabled,
JSON.stringify(manualPlayTarget),
);
if (manualPlayTarget.visible && manualPlayTarget.enabled) {
await page.click('#context-radio-mini-play-btn');
await page.click('#radio-play-btn');
await page.waitForFunction(() => (
window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'
&& window.__godsEyeView.voiceCommands.status === 'idle'
@@ -2781,7 +2622,6 @@ async function main() {
await page.evaluate(() => window.__godsEyeView.voiceCommands.setStatus('idle', 'QA complete'));
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
manager._setRadioDisclosure(false);
manager.setPanelCollapsed('global-context-panel', false);
manager.setPanelCollapsed('radio-panel', false);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
@@ -3835,22 +3675,22 @@ async function main() {
gev.dataManager.layers.get('flights').enabled = true;
});
await sleep(500);
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true));
await page.evaluate(() => {
const button = document.getElementById('context-radio-toggle-btn');
if (button.getAttribute('aria-expanded') !== 'true') button.click();
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('global-context-panel', false);
manager.setPanelCollapsed('radio-panel', false);
});
await page.waitForSelector('#context-radio-mini', { visible: true });
await page.waitForSelector('#radio-next-btn', { visible: true });
await page.evaluate(() => {
window.__qaRadioCameraHeight = window.__godsEyeView.viewer.camera.positionCartographic.height;
});
const radioOnlyNextDispatched = await page.evaluate(() => {
const button = document.getElementById('context-radio-mini-next-btn');
const button = document.getElementById('radio-next-btn');
if (!button) return false;
button.click();
return true;
});
if (!radioOnlyNextDispatched) throw new Error('Radio compact next button unavailable');
if (!radioOnlyNextDispatched) throw new Error('Radio next button unavailable');
await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function');
await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
@@ -3885,7 +3725,7 @@ async function main() {
flyToCalls: window.__qaRadioFlyToCalls.length,
flightTargets,
cameraHeight: window.__qaRadioCameraHeight,
compactPlayLabel: document.getElementById('context-radio-mini-play-btn').getAttribute('aria-label'),
playLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'),
regularMarkerSize: Array.from({ length: window.__godsEyeView.viewer.dataSources.length }, (_, index) => window.__godsEyeView.viewer.dataSources.get(index))
.find((item) => item.name === 'Radio stations')?.entities.values[0]?.point?.pixelSize?.getValue(),
selectedMarkerSize: selectedEntity?.point?.pixelSize?.getValue(),
@@ -3898,7 +3738,7 @@ async function main() {
};
});
check(
'compact Next follows stable directory order and retries its next available station',
'Radio Next follows stable directory order and retries its next available station',
playing.state.selected?.id === playing.expectedFallback?.id
&& playing.calls[0] === playing.expectedPrimary?.streamUrl
&& playing.calls[1] === playing.expectedFallback?.streamUrl,
@@ -3909,9 +3749,9 @@ async function main() {
fallback: playing.expectedFallback?.id,
}),
);
check('compact next is explicit playback and updates play/pause state', playing.calls.length === 2 && playing.compactPlayLabel.startsWith('Pause'));
check('Radio next is explicit playback and updates play/pause state', playing.calls.length === 2 && playing.playLabel.startsWith('Pause'));
check(
'compact next rotates toward its station without changing zoom, including fallback',
'Radio next rotates toward its station without changing zoom, including fallback',
playing.flyToCalls === 2
&& Math.abs(playing.flightTargets[0]?.lat - playing.expectedPrimary?.lat) < 0.001
&& Math.abs(playing.flightTargets[0]?.lon - playing.expectedPrimary?.lon) < 0.001
@@ -3950,11 +3790,11 @@ async function main() {
};
return [
await run('flights', 'radio-next-btn'),
await run('military', 'context-radio-mini-prev-btn'),
await run('military', 'radio-prev-btn'),
];
});
check(
'full and compact Radio navigation preserve Flights/Military camera ownership',
'full Radio Next and Prev navigation preserve Flights/Military camera ownership',
trackedRadioControls.every((sample) => sample.afterStationId
&& sample.afterStationId !== sample.beforeStationId
&& sample.flyToDelta === 0
@@ -3968,7 +3808,7 @@ async function main() {
viewer.trackedEntity = undefined;
window.__qaRadioDelayNextFailure = true;
const beforeFlyToCalls = window.__qaRadioFlyToCalls.length;
document.getElementById('context-radio-mini-next-btn')?.click();
document.getElementById('radio-next-btn')?.click();
return { beforeFlyToCalls };
});
await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function');
@@ -4244,7 +4084,7 @@ async function main() {
window.__godsEyeView.dataManager.layers.get('flights').enabled = false;
});
const radioOnlyNextClicked = await page.evaluate(() => {
const button = document.getElementById('context-radio-mini-next-btn');
const button = document.getElementById('radio-next-btn');
if (!(button instanceof HTMLButtonElement)) return false;
button.click();
return true;
@@ -4508,106 +4348,12 @@ async function main() {
&& companion.radioCollapsed && !companion.contextCollapsed && companion.nested,
);
const expandedContextRadioBefore = await page.evaluate(() => {
const gev = window.__godsEyeView;
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
scroller.scrollTop = 0;
const camera = gev.viewer.camera.positionWC;
return {
scrollTop: scroller.scrollTop,
pageY: window.scrollY,
camera: { x: camera.x, y: camera.y, z: camera.z },
plays: window.__qaRadioPlayCalls.length,
};
});
await page.click('#context-radio-toggle-btn');
await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed'));
await page.waitForFunction((priorScroll) => (
document.activeElement?.getAttribute('data-collapse-target') === 'radio-panel'
&& document.querySelector('#global-context-panel .global-context-panel-inner').scrollTop > priorScroll
), { timeout: 10_000 }, expandedContextRadioBefore.scrollTop);
const expandedContextRadioAfter = await page.evaluate(() => {
const gev = window.__godsEyeView;
gev.styleManager._renderRadioState(gev.dataManager.layers.get('radio').module.getUIState());
const launcher = document.getElementById('context-radio-toggle-btn');
const radio = document.getElementById('radio-panel');
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
const camera = gev.viewer.camera.positionWC;
return {
ariaControls: launcher.getAttribute('aria-controls'),
expanded: launcher.getAttribute('aria-expanded'),
label: launcher.getAttribute('aria-label'),
radioCollapsed: radio.classList.contains('collapsed'),
compactHidden: document.getElementById('context-radio-mini').hidden,
scrollTop: scroller.scrollTop,
focusTarget: document.activeElement?.getAttribute('data-collapse-target'),
pageY: window.scrollY,
camera: { x: camera.x, y: camera.y, z: camera.z },
plays: window.__qaRadioPlayCalls.length,
state: gev.dataManager.layers.get('radio').module.getUIState().audioState,
};
});
const expandedContextRadioCameraDelta = Math.hypot(
expandedContextRadioAfter.camera.x - expandedContextRadioBefore.camera.x,
expandedContextRadioAfter.camera.y - expandedContextRadioBefore.camera.y,
expandedContextRadioAfter.camera.z - expandedContextRadioBefore.camera.z,
);
check(
'expanded Context Radio icon reveals and scrolls to embedded Radio without an overlay or playback/camera theft',
expandedContextRadioAfter.ariaControls === 'radio-panel'
&& expandedContextRadioAfter.expanded === 'true'
&& expandedContextRadioAfter.label === 'Go to expanded Radio section'
&& !expandedContextRadioAfter.radioCollapsed
&& expandedContextRadioAfter.compactHidden
&& expandedContextRadioAfter.scrollTop > expandedContextRadioBefore.scrollTop
&& expandedContextRadioAfter.focusTarget === 'radio-panel'
&& expandedContextRadioAfter.pageY === expandedContextRadioBefore.pageY
&& expandedContextRadioCameraDelta < 0.01
&& expandedContextRadioAfter.plays === expandedContextRadioBefore.plays
&& expandedContextRadioAfter.state === 'playing',
JSON.stringify({
before: expandedContextRadioBefore,
after: expandedContextRadioAfter,
cameraDelta: expandedContextRadioCameraDelta,
}),
);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('global-context-panel', true);
manager._setRadioDisclosure(true);
manager.setPanelCollapsed('global-context-panel', false);
manager.setPanelCollapsed('radio-panel', false);
});
await page.waitForFunction(() => {
const close = document.getElementById('context-radio-mini-close-btn');
const rect = close.getBoundingClientRect();
return !document.getElementById('context-radio-mini').hidden && rect.width > 0 && rect.height > 0;
});
await page.click('#context-radio-mini-close-btn');
const closedCompact = await page.evaluate(() => {
const launcher = document.getElementById('context-radio-toggle-btn');
return {
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
compactHidden: document.getElementById('context-radio-mini').hidden,
focusRestored: document.activeElement === launcher,
};
});
await page.click('#context-radio-toggle-btn');
await page.waitForFunction(() => !document.getElementById('context-radio-mini').hidden);
await page.click('#context-radio-details-btn');
const repeatedOpen = await page.evaluate(() => ({
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
compactHidden: document.getElementById('context-radio-mini').hidden,
}));
const splitDetailActions = { closedCompact, repeatedOpen };
check(
'collapsed Context keeps compact close and full-panel open as separate stable actions',
!splitDetailActions.closedCompact.radioCollapsed
&& splitDetailActions.closedCompact.compactHidden
&& splitDetailActions.closedCompact.focusRestored
&& !splitDetailActions.repeatedOpen.radioCollapsed
&& splitDetailActions.repeatedOpen.compactHidden,
JSON.stringify(splitDetailActions),
);
await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed'));
await page.click('#global-context-flights-btn');
await page.waitForFunction(() => !document.getElementById('context-flights-view').hidden);
+5 -12
View File
@@ -345,7 +345,7 @@ try {
(await readScope()).feather === 2,
);
await page.click('#scope-toggle');
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) {
for (const layout of ['minimal', 'cyber', 'tactical']) {
await page.select('#hud-layout-select', layout);
const state = await readScope();
check(
@@ -395,7 +395,7 @@ try {
restored,
);
for (const layout of ['minimal', 'operator', 'tactical']) {
for (const layout of ['minimal', 'tactical']) {
await page.select('#hud-layout-select', layout);
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
@@ -429,20 +429,13 @@ try {
wrappersTransparent: wrappers.every(
(el) => getComputedStyle(el).display === 'contents',
),
operatorFrame:
hud.dataset.variant !== 'operator' ||
(getComputedStyle(hud.querySelector('.hud-corner'))
.backgroundColor === 'rgba(0, 0, 0, 0.36)' &&
getComputedStyle(hud.querySelector('.hud-top-bar'))
.backgroundImage !== 'none'),
};
});
check(
`${layout}: Scope ON retains the original presentation without new backplates`,
original.markerAbsent &&
original.unbacked &&
original.wrappersTransparent &&
original.operatorFrame,
original.wrappersTransparent,
original,
);
check(
@@ -571,7 +564,7 @@ try {
);
});
const broadBackings = [
...hud.querySelectorAll('.hud-corner, .hud-top-bar, .hud-bottom-bar'),
...hud.querySelectorAll('.hud-corner, .hud-bottom-bar'),
].filter(
(el) =>
el.offsetWidth > 0 &&
@@ -656,7 +649,7 @@ try {
}),
);
check('narrow HUD text backings stay within the viewport', narrow);
for (const layout of ['minimal', 'operator', 'tactical']) {
for (const layout of ['minimal', 'tactical']) {
await page.select('#hud-layout-select', layout);
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
-1
View File
@@ -80,7 +80,6 @@ try {
'thermal',
'anime',
'noir',
'snow',
'normal',
]) {
await page.evaluate(
+3 -3
View File
@@ -46,15 +46,15 @@ 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.',
'HUD requests ("hud on/off", "switch to 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.',
'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.',
'Radio playback requests use control_radio. "Turn on/start the radio" means action=play; action=enable only reveals Radio markers and must be reserved for explicit "show/enable the Radio layer/markers" requests. After a prepared playback result, briefly confirm any other completed actions and say "Turning on the radio"—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "play news near Austin" → select category=news locationId=austin; "play US news" → select category=news country=US; "Radio volume 30" → volume; pause/resume/stop/next/previous use the matching action. Radio selection never moves the camera.',
'"Track/follow <something specific>" (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.',
'"Show me which planes are overhead"/"frame the ships"/"show me the satellites above" use frame_overhead with the matching target.',
'Confirmations echo the RESULTING state, never the request: "HUD operator layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.',
'Confirmations echo the RESULTING state, never the request: "HUD minimal layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.',
'For destination requests such as "take me to Italy", "go to NYC", or "show me the Eiffel Tower", call fly_to_location. Prefer known city IDs when available; otherwise pass the plain place query.',
'Navigation-only requests ("take me to X", "go to X", "fly to X") are NOT descriptions: call fly_to_location alone and do NOT also call annotate_map, unless the user explicitly asks to mark the place or you go on to explain specific places there. Never drop a point pin on a region-scale natural feature (a mountain range, desert, sea, or forest) — a single point in the middle of the Rockies is meaningless. If the user explicitly asks to mark such a region, prefer type=area.',
'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.',
+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);
}
});
+4 -8
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/);
@@ -227,13 +227,9 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
);
});
test('Cockpit shortcut failures do not leak and open Radio owns the first Escape', () => {
test('Cockpit shortcut failures do not leak and open utilities own the first Escape', () => {
const keydown = cockpitKeyDown.toString();
assert.ok(keydown, 'Cockpit keyboard handler is missing');
assert.match(
keydown,
/document\s*\.getElementById\('context-radio-dock'\)\s*\?\.classList\.contains\('disclosure-open'\)/,
);
assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/);
assert.match(
keydown,
@@ -443,7 +439,7 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
assert.ok(cockpitRadio, 'Cockpit compact Radio controls are missing');
assert.doesNotMatch(
cockpitRadio[0],
/context-radio-details-btn|radio-filter|radio-tuner/,
/radio-filter|radio-tuner/,
);
assert.match(
css,
@@ -899,7 +895,7 @@ test('Cockpit Radio station changes preserve first-person camera ownership', ()
);
assert.match(
radioBindings,
/listen\(this\._contextRadioMiniNextBtn, 'click', \(\) => cycleRadio\(1\)\)/,
/listen\(this\._radioNextBtn, 'click', \(\) => cycleRadio\(1\)\)/,
);
assert.match(
radioBindings,
-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');
});
+35 -2
View File
@@ -129,7 +129,7 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs',
test('Context async action buttons remain focused while busy', () => {
const init = _initGlobalContextPanel.toString();
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,'));
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._cockpitRadioToggleBtn,'));
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
const clear = clearSelectedLayers.toString();
@@ -137,7 +137,7 @@ test('Context async action buttons remain focused while busy', () => {
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
for (const name of ['_radioEnableBtn', '_contextRadioMiniEnableBtn', '_cockpitRadioEnableBtn']) {
for (const name of ['_radioEnableBtn', '_cockpitRadioEnableBtn']) {
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
}
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
@@ -146,3 +146,36 @@ test('Context async action buttons remain focused while busy', () => {
assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/);
assert.doesNotMatch(control, /button\.disabled\s*=/);
});
test('Context header holds only its title and collapse control', () => {
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body"/);
assert.ok(panel, 'Global Context panel is missing');
const header = panel[0].match(/<div class="panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body"/);
assert.ok(header, 'Global Context header is missing');
const tags = [...header[1].matchAll(/<(\w+)\s+class="([^"]+)"/g)].map(([, tag, cls]) => `${tag}.${cls}`);
assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']);
});
test('Context reads as one segmented control with legible type and no halo', () => {
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
const clean = (selector) => selector.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim();
const rule = (selector) => rules.find(([, s]) => clean(s) === selector)?.[2] || '';
const px = (body, prop) => Number(body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0);
assert.match(rule('#global-context-panel .panel-glow'), /display:\s*none/);
const modes = rule('.global-context-modes');
assert.match(modes, /border:\s*1px solid/);
assert.match(modes, /padding:\s*2px/);
assert.ok(px(rule('.context-mode-button'), 'font') >= 10, 'mode labels must stay at least 10px');
assert.doesNotMatch(rule('.context-mode-button.active'), /box-shadow/);
const standby = rule('.context-mode-standby');
assert.ok(px(standby, 'font') >= 10, 'standby guidance must stay at least 10px');
assert.doesNotMatch(standby, /dashed/);
assert.ok(px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10);
// Default theme only, expanded only: Cyber keeps its red titles and the
// collapsed launcher matches its sibling launchers.
assert.match(
rule(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"),
/color:\s*var\(--text-secondary\)/,
);
});
+2
View File
@@ -47,6 +47,8 @@ export const prioritizeActiveCctvGeometryRecord =
export const processGeometryBatch = layer.processGeometryBatch;
export const hideCctvRecordVisuals = layer.hideCctvRecordVisuals;
export const refreshCoverageStyles = layer.refreshCoverageStyles;
export const refreshHorizonCulling = layer.refreshHorizonCulling;
export const _viewRecordIdsForTest = layer._viewRecordIdsForTest;
export const clearProbeClampOnDeactivation =
layer.clearProbeClampOnDeactivation;
export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation;
+3 -2
View File
@@ -1309,11 +1309,12 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
assert.ok(renderer, '_renderCctvState is missing');
assert.match(
renderer[0],
/else if \(!activeId\)[\s\S]*?selectedIndex = -1/,
// Nothing active clears the pick; only the outside-view note may show.
/else if \(!activeId\)[\s\S]*?selectedIndex =[^;]*\? 0 : -1;/,
);
assert.match(
renderer[0],
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*cameras\.length === 0\s*\|\|\s*!activeId/,
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*catalogCount === 0\s*\|\|\s*!activeId/,
);
} finally {
cctvLayer.setParams({ autoHop: false });
+251
View File
@@ -0,0 +1,251 @@
// src/data/cctvViewThinning.test.mjs — the CCTV view pass: only cameras on or
// near the screen show, a zoomed-out view keeps one camera per world-anchored
// cell, and the layer's UI state never rebuilds every camera for a listener
// that does not read the list.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import cctvLayer, {
_setCctvCoverageStateForTest,
_viewRecordIdsForTest,
refreshHorizonCulling,
} from './cctv.js';
const WIDTH = 800;
const HEIGHT = 600;
/**
* Records at the given [lon, lat] degrees near the origin, each drawn at the
* screen point `screenAt` gives it (default: the middle of the screen).
*/
function recordsAt(
lonLats,
screenAt = () => ({ x: WIDTH / 2, y: HEIGHT / 2 }),
) {
const screenByPosition = new Map();
const records = lonLats.map(([lon, lat], index) => {
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 10);
screenByPosition.set(position, screenAt(index));
return {
camera: {
id: `cam-${index}`,
name: `Camera ${index}`,
city: 'Test',
lat,
lon,
headingDeg: 0,
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
},
position,
billboard: { position, show: true },
coverageEntities: [],
projection: null,
};
});
return { records, screenByPosition };
}
function viewerAt(heightM, screenByPosition, shiftX = 0) {
return {
isDestroyed: () => false,
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, heightM),
positionCartographic: { latitude: 0, longitude: 0, height: heightM },
frustum: { fovy: Math.PI / 3 },
},
scene: {
canvas: { clientWidth: WIDTH, clientHeight: HEIGHT },
cartesianToCanvasCoordinates: (position) => {
const screen = screenByPosition.get(position);
return screen && { x: screen.x + shiftX, y: screen.y };
},
},
};
}
function shownIds(records) {
return records
.filter((record) => record.billboard.show)
.map((record) => record.camera.id)
.sort();
}
// Three cameras within ~110 m, and one ~22 km away.
const CLUSTER = [
[0, 0],
[0.001, 0],
[0.0005, 0.0005],
[0.2, 0],
];
test('zoomed out, cameras sharing a ground cell show one icon', () => {
const { records, screenByPosition } = recordsAt(CLUSTER);
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
});
assert.equal(
refreshHorizonCulling(),
true,
'the first pass changes the view',
);
const shown = shownIds(records);
assert.equal(shown.length, 2, 'one for the cluster, one for the far camera');
assert.ok(shown.includes('cam-3'), 'a camera alone in its cell shows');
assert.deepEqual(_viewRecordIdsForTest().sort(), shown);
});
test('a pan keeps the same winners, and an unchanged view keeps its list', () => {
const { records, screenByPosition } = recordsAt(CLUSTER, (index) => ({
x: 100 + index * 7,
y: 300,
}));
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
});
refreshHorizonCulling();
const before = shownIds(records);
const list = _viewRecordIdsForTest();
for (const shiftX of [3, 11, 20, 37]) {
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition, shiftX),
records,
});
refreshHorizonCulling();
assert.deepEqual(shownIds(records), before, `pan by ${shiftX} px`);
}
assert.equal(refreshHorizonCulling(), false, 'the same view changes nothing');
assert.deepEqual(_viewRecordIdsForTest(), list);
});
test('a small zoom within one cell size keeps the same winners', () => {
const { records, screenByPosition } = recordsAt(CLUSTER);
const shownAt = (heightM) => {
_setCctvCoverageStateForTest({
viewer: viewerAt(heightM, screenByPosition),
records,
});
refreshHorizonCulling();
return shownIds(records);
};
// Cells come in power-of-two metre steps: about 1.5 km and 1.6 km cells at
// these heights fall in the same step, so the same cameras win.
assert.deepEqual(shownAt(20_500), shownAt(20_000));
});
test('a healthy camera wins its cell over an unknown one', () => {
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
// Whichever camera the id order would pick, the healthy one must win.
for (const id of ['cam-0', 'cam-1']) {
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
healthById: new Map([[id, { status: 'ok' }]]),
});
refreshHorizonCulling();
assert.deepEqual(shownIds(records), [id]);
}
});
test('the active camera always shows, even in a crowded cell', () => {
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 3));
for (const active of records) {
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
activeCameraId: active.camera.id,
});
refreshHorizonCulling();
assert.ok(active.billboard.show, `${active.camera.id} shows while active`);
}
});
test('close up, every camera near the screen shows and far-off ones hide', () => {
const { records, screenByPosition } = recordsAt(
[
[0, 0],
[0.0001, 0],
[0.0002, 0],
],
(index) =>
index === 2 ? { x: WIDTH * 3, y: HEIGHT / 2 } : { x: 100, y: 100 },
);
_setCctvCoverageStateForTest({
viewer: viewerAt(300, screenByPosition),
records,
});
refreshHorizonCulling();
assert.deepEqual(shownIds(records), ['cam-0', 'cam-1']);
});
test('a disabled layer skips the view pass', () => {
const { records, screenByPosition } = recordsAt(CLUSTER);
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
enabled: false,
});
assert.equal(refreshHorizonCulling(), false);
assert.deepEqual(_viewRecordIdsForTest(), []);
});
test('UI state builds the full camera list only when it is read', () => {
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
_setCctvCoverageStateForTest({
viewer: viewerAt(300, screenByPosition),
records,
});
refreshHorizonCulling();
let built = 0;
for (const record of records) {
const camera = record.camera;
record.camera = new Proxy(camera, {
get(target, key) {
if (key === 'calibration') built += 1;
return target[key];
},
});
}
const first = cctvLayer.getUIState();
assert.equal(built, 0, 'listeners that skip `cameras` cost nothing');
assert.deepEqual(
first.cameraIndex.map((camera) => camera.id),
['cam-0', 'cam-1'],
);
assert.equal(
cctvLayer.getUIState().cameraIndex,
first.cameraIndex,
'the index keeps its identity while the view does',
);
assert.equal(first.cameras.length, 2);
assert.ok(built > 0, 'reading `cameras` builds it');
assert.equal(first.cameras, first.cameras, 'and builds it once');
});
test('the camera picker lists the view, with the active camera first', () => {
const { records, screenByPosition } = recordsAt(
[
[0, 0],
[0.0001, 0],
[0.0002, 0],
],
(index) => (index === 1 ? { x: WIDTH * 3, y: 0 } : { x: 100, y: 100 }),
);
_setCctvCoverageStateForTest({
viewer: viewerAt(300, screenByPosition),
records,
activeCameraId: 'cam-1',
});
refreshHorizonCulling();
const state = cctvLayer.getUIState();
assert.deepEqual(
state.cameraIndex.map((camera) => camera.id),
['cam-1', 'cam-0', 'cam-2'],
'the off-screen active camera leads, then what is in view',
);
assert.equal(state.cameras.length, 3, 'the full list is still every camera');
});
+29
View File
@@ -875,6 +875,13 @@ test('route numbers follow the page language and never break from their unit', (
);
const list = directionsStepList({ route });
assert.equal(list.items[1].lead, '240\u00a0м');
// Japanese unit words follow the number: 1時間5分, but 1.5 km.
setLocale('ja');
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
assert.equal(routeDurationText(39), '39秒');
assert.equal(routeDurationText(3900), '1時間5分');
assert.equal(routeDurationText(7200), '2時間');
});
test('ramp and lane wording stays localized after choosing the maneuver form', (t) => {
@@ -892,6 +899,12 @@ test('ramp and lane wording stays localized after choosing the maneuver form', (
'Toma la salida bruscamente a la derecha',
'Toma el carril izquierdo',
],
[
'ja',
'ランプを斜め左に進んでI-95に入る',
'出口を急角度で右に出る',
'左車線を通行',
],
[
'zh-CN',
'向左前方驶入匝道,进入 I-95',
@@ -919,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',
);
});
+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 = {
+27 -16
View File
@@ -374,24 +374,26 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
assert.match(covered, /\} catch \{\s*\n\s*return false;\s*\n\s*\}/);
});
test('one ESC does one thing — the radio disclosure stops the launcher outright', () => {
test('one ESC does one thing — a Cockpit utility disclosure stops later document listeners', () => {
const ui = fs.readFileSync(
new URL('./ui/radioBindings.js', import.meta.url),
'utf8',
);
// stopPropagation() does NOT stop later listeners on the SAME document, so the
// disclosure's earlier capture handler closed the disclosure and the launcher
// dismissed itself off the same key. The earlier listener is the only one that
// can stop the later one — and only the immediate form does it.
const radioEsc = ui.slice(ui.indexOf("event.key !== 'Escape' ||"));
const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false'));
// stopPropagation() does NOT stop later listeners on the SAME document. The
// Context Radio disclosure that once raced the launcher this way is gone, and
// Cockpit's utility disclosures never share the screen with the launcher, but
// they own the same contract: their capture handler is the only one that can
// keep a later document listener (Cockpit exit, the launcher) off the key, and
// only the immediate form does it.
const utilityEsc = ui.slice(ui.indexOf('if (!displayOpen && !radioOpen) return;'));
const claim = utilityEsc.slice(0, utilityEsc.indexOf('setCockpitDisclosure(kind, false'));
assert.match(claim, /event\.preventDefault\(\);/);
assert.match(claim, /event\.stopImmediatePropagation\(\);/);
assert.doesNotMatch(
claim,
/event\.stopPropagation\(\);/,
'the plain form leaves the launcher listening and is the defect itself',
'the plain form leaves later document listeners acting on the same key',
);
// Belt on the launcher side: a key another surface already marked is not ours,
@@ -1022,14 +1024,10 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
);
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud')
.parameters.properties.layout;
assert.deepEqual(hudLayout.enum, [
'tactical',
'operator',
'minimal',
'cyber',
]);
// Cyber deliberately adds one layout; first-run missions still change no tools.
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
assert.deepEqual(hudLayout.enum, ['tactical', 'minimal', 'cyber']);
// Cyber deliberately adds one layout and Operator was retired; first-run
// missions still change no tools.
hudLayout.enum = ['tactical', 'operator', 'minimal'];
const cameraMotion = legacyTools.find((tool) => tool.name === 'move_camera')
.parameters.properties.motion;
assert.deepEqual(cameraMotion.enum, [
@@ -1040,6 +1038,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
+2 -9
View File
@@ -9,7 +9,7 @@ import { applicationServices } from './services/application.js';
* real-time at configurable cadences.
*
* The HUD auto-activates when a military-style shader (NVG, FLIR, CRT) is
* selected and supports three layout variants: tactical, operator, minimal.
* selected and supports the layout variants: tactical, minimal, cyber.
*
* Color theming is driven by the active shader mode via CSS custom properties.
*/
@@ -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. */
@@ -288,12 +287,6 @@ export class IntelHUD {
this._el.innerHTML = `
<div class="hud-sonar" aria-hidden="true"></div>
<div class="hud-top-bar">
<span class="hud-readout"><span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span></span>
<span class="hud-readout"><span class="hud-top-bar-center">${this._missionId}</span></span>
<span class="hud-readout"><span class="hud-top-bar-right">PAGE 1/1</span></span>
</div>
<div class="hud-corner hud-top-left">
<div class="hud-bracket">┌</div>
<div class="hud-content">
@@ -1063,7 +1056,7 @@ export class IntelHUD {
* Switch the HUD layout variant. Falls back to `'tactical'` if the
* name is unrecognized. Cyber also applies a coordinated skin to the full
* application shell; leaving Cyber restores the standard shell tokens.
* @param {string} variantName - One of `'tactical'`, `'operator'`, `'minimal'`, `'cyber'`.
* @param {string} variantName - One of `'tactical'`, `'minimal'`, `'cyber'`.
*/
setVariant(variantName) {
this._variant = normalizeHudLayout(variantName);
+22
View File
@@ -575,3 +575,25 @@ test('a summary interrupted while typing can retry unchanged context', async (t)
env.restore();
}
});
// ── Retired layouts ─────────────────────────────────────────────────────────
//
// Share links keep the retired Operator code, so old links still name it. The
// real HUD, not a caller, is what turns it into the default Tactical layout.
test('a retired Operator layout falls back to Tactical in the live HUD', () => {
const env = installHudEnvironment();
let hud;
try {
hud = new IntelHUD(env.viewer);
hud._el = { dataset: {} };
hud.setVariant('minimal');
assert.equal(hud.getVariant(), 'minimal');
hud.setVariant('operator');
assert.equal(hud.getVariant(), 'tactical');
assert.equal(hud._el.dataset.variant, 'tactical');
} finally {
hud?.destroy();
env.restore();
}
});
+1 -6
View File
@@ -1,7 +1,2 @@
/** Browser-free layout vocabulary shared by UI and server-side voice tools. */
export const HUD_LAYOUTS = Object.freeze([
'tactical',
'operator',
'minimal',
'cyber',
]);
export const HUD_LAYOUTS = Object.freeze(['tactical', 'minimal', 'cyber']);
+5 -2
View File
@@ -8,7 +8,10 @@ import { formatNumber, getLocale, t } from './i18n/index.js';
import { unitLabel } from './ui/unitLabels.js';
import { localizePlaceName } from './ui/placeNames.js';
/** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */
/**
* An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language.
* Chinese and Japanese have no case and keep the catalog's `13 km`.
*/
export function hudQuantity(value, unit, digits = 0) {
const number = formatNumber(value, {
useGrouping: false,
@@ -16,7 +19,7 @@ export function hudQuantity(value, unit, digits = 0) {
maximumFractionDigits: digits,
});
const label = unitLabel(unit);
if (getLocale().startsWith('zh-'))
if (/^(zh|ja)(-|$)/.test(getLocale()))
return label ? `${number} ${label}` : number;
return `${number}${label.toUpperCase()}`;
}
+77 -2
View File
@@ -5,6 +5,7 @@ import {
applyDocumentTranslations,
formatDate,
formatNumber,
formatRelativeTime,
msg,
persistLocaleAndReload,
setLocale,
@@ -33,8 +34,23 @@ 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');
// Explicit script tags take precedence over regional defaults.
assert.equal(normalizeLocale('zh'), 'zh-CN');
assert.equal(normalizeLocale('zh-CN'), 'zh-CN');
@@ -68,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',
);
@@ -76,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',
);
@@ -142,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');
@@ -164,7 +218,13 @@ test('Intl formatters follow the active locale', () => {
assert.equal(formatNumber(1234.5), '1,234.5');
setLocale('ru');
assert.equal(formatDate(date, options), '15 января 2026 г.');
assert.equal(formatRelativeTime(-5, 'minute'), '5 минут назад');
assert.equal(formatNumber(1234.5), '1 234,5');
// Japanese keeps a number and its unit together, as the catalog does.
setLocale('ja');
assert.equal(formatDate(date, options), '2026年1月15日');
assert.equal(formatRelativeTime(-5, 'minute'), '5分前');
assert.equal(formatRelativeTime(-3, 'day'), '3日前');
});
function fakeElement({ text = '', attributes = {} } = {}) {
@@ -200,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 = {
+33 -5
View File
@@ -16,12 +16,29 @@ 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({ es, ru, 'zh-CN': zhCN, 'zh-Hant': zhHant });
const CATALOGS = Object.freeze({
de,
es,
fr,
it,
ja,
pt,
ru,
uk,
'zh-CN': zhCN,
'zh-Hant': zhHant,
});
let currentLocale = DEFAULT_LOCALE;
@@ -201,13 +218,19 @@ export function formatUtcDayMonth(value) {
/**
* Locale-aware relative time (Intl.RelativeTimeFormat), e.g.
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад'.
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад' /
* '5分前'.
*/
export function formatRelativeTime(value, unit = 'second', options) {
return cachedFormatter('relative', Intl.RelativeTimeFormat, {
const text = cachedFormatter('relative', Intl.RelativeTimeFormat, {
numeric: 'auto',
...options,
}).format(value, unit);
// CLDR spaces Japanese (5 分前); the ja catalog writes the number and its
// unit together (5分前), so the two must not differ on one screen.
return currentLocale === 'ja'
? text.replace(/(?<=\d) (?=\p{Script=Han})/gu, '')
: text;
}
/** Collapse markup whitespace so a message reads the same in HTML and JS. */
@@ -221,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],
@@ -246,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;
+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from './index.js';
import {
cockpitUnitCase,
formatCockpitDistance,
} from '../ui/cockpitPresentation.js';
test('Japanese cockpit distances keep the unit in its own case', () => {
try {
setLocale('ja');
assert.equal(formatCockpitDistance(14000), '14 km');
assert.equal(formatCockpitDistance(9500), '9.5 km');
assert.equal(cockpitUnitCase('km'), 'km');
setLocale('en');
assert.equal(formatCockpitDistance(14000), '14 KM');
assert.equal(cockpitUnitCase('km'), 'KM');
} finally {
setLocale('en');
}
});
+22
View File
@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from './index.js';
import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js';
import { formatCockpitDistance } from '../ui/cockpitPresentation.js';
// revmain-opus-ja-i1-1: ja has no case, so HUD units keep the catalog case
// (km), as the cockpit already does and docs/translations/ja.md requires.
test('Japanese HUD units keep their case, like the cockpit', () => {
try {
setLocale('ja');
assert.equal(formatCockpitDistance(14000), '14 km');
assert.equal(hudQuantity(13, 'km'), '13 km');
assert.equal(hudQuantity(1.5, 'km', 1), '1.5 km');
assert.equal(
LOCALIZED_LOCALITY.near({ poi: 'Shibuya', city: 'Tokyo', distKm: 13 }),
'SHIBUYA(東京)付近 13 km',
);
} finally {
setLocale('en');
}
});
+21 -1
View File
@@ -8,13 +8,31 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
export const DEFAULT_LOCALE = 'en';
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN', 'zh-Hant']);
export const LOCALES = Object.freeze([
'en',
'de',
'es',
'fr',
'it',
'ja',
'pt',
'ru',
'uk',
'zh-CN',
'zh-Hant',
]);
/** 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': '中文(繁體)',
});
@@ -24,6 +42,8 @@ const RTL_LOCALES = Object.freeze([]);
/**
* 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
+7 -15
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",
@@ -76,7 +74,6 @@
"Layout": "Disposición",
"HUD layout": "Disposición del HUD",
"Tactical": "Táctica",
"Operator": "De operador",
"Minimal": "Mínima",
"Detection Overlay (D)": "Superposición de detección (D)",
"Detection overlay": "Superposición de detección",
@@ -198,21 +195,13 @@
"AVAILABLE MISSIONS": "MISIONES DISPONIBLES",
"SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR",
"LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS",
"Open compact Radio controls": "Abrir los controles compactos de Radio",
"Compact Radio controls": "Controles compactos de Radio",
"RADIO": "RADIO",
"RADIO READY": "RADIO LISTA",
"Open detailed Radio controls": "Abrir los controles detallados de Radio",
"Close compact Radio controls": "Cerrar los controles compactos de Radio",
"ENABLE": "ACTIVAR",
"Previous filtered radio station": "Emisora de radio filtrada anterior",
"Previous station": "Emisora anterior",
"Play selected radio station": "Reproducir la emisora de radio seleccionada",
"Play": "Reproducir",
"Next filtered radio station": "Siguiente emisora de radio filtrada",
"Next station": "Siguiente emisora",
"VOLUME": "VOLUMEN",
"Compact Radio volume": "Volumen de la Radio compacta",
"Cockpit display and Radio controls": "Controles de visualización y Radio de la cabina",
"Expand Cockpit display options": "Expandir las opciones de visualización de la cabina",
"Cockpit display options": "Opciones de visualización de la cabina",
@@ -357,8 +346,6 @@
"selected": "seleccionada",
"nearest": "más cercana",
"{action} {target} radio station": "{action} la emisora de radio {target}",
"RADIO STATE UNCERTAIN": "ESTADO DE RADIO SIN CONFIRMAR",
"SYNCING DIRECTORY": "SINCRONIZANDO DIRECTORIO",
"SYNCING": "SINCRONIZANDO",
"station": "emisora",
"Ready": "Listo",
@@ -382,6 +369,11 @@
"many": "{count} de cámaras cargadas · activa CCTV para activarlas",
"other": "{count} cámaras cargadas · activa CCTV para activarlas"
},
"{count} more cameras outside this view · pan or zoom out": {
"one": "{count} cámara más fuera de esta vista · desplaza o aleja",
"many": "{count} de cámaras más fuera de esta vista · desplaza o aleja",
"other": "{count} cámaras más fuera de esta vista · desplaza o aleja"
},
"No summary available.": "No hay resumen disponible.",
"Celestial ring — available in Normal style": "Anillo celestial — disponible en el estilo Normal",
"Detection overlay: {mode}": "Superposición de detección: {mode}",
@@ -2230,6 +2222,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}",
@@ -2445,8 +2439,6 @@
"Exit cockpit to fly to a route": "Sal de la cabina para volar a una ruta",
"Exit cockpit to fly to a file": "Sal de la cabina para volar a un archivo",
"Exit cockpit to fly to a scene": "Sal de la cabina para volar a una escena",
"Go to expanded Radio section": "Ir a la sección de Radio expandida",
"Expand Radio section in Context": "Expandir la sección de Radio en Contexto",
"New scene name": "Nombre de la nueva escena",
"Delete scene \"{scene}\" and all shots?": "¿Eliminar la escena \"{scene}\" y todos sus planos?",
"Detailed outline unavailable": "Contorno detallado no disponible",
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
File diff suppressed because it is too large. Load diff
+8 -15
View File
@@ -342,6 +342,12 @@
"many": "Загружено {count} камер · включите CCTV, чтобы активировать",
"other": "Загружено {count} камеры · включите CCTV, чтобы активировать"
},
"{count} more cameras outside this view · pan or zoom out": {
"one": "Ещё {count} камера вне этого вида · сдвиньте или отдалите",
"few": "Ещё {count} камеры вне этого вида · сдвиньте или отдалите",
"many": "Ещё {count} камер вне этого вида · сдвиньте или отдалите",
"other": "Ещё {count} камеры вне этого вида · сдвиньте или отдалите"
},
"Enable CCTV to load camera intersections": "Включите CCTV, чтобы загрузить камеры на перекрёстках",
"Enable CCTV to start camera-linked intelligence summaries.": "Включите CCTV, чтобы запускать сводки по камерам.",
"No summary available.": "Сводка недоступна.",
@@ -416,7 +422,6 @@
"Thermal": "Тепловизор",
"Anime": "Аниме",
"Noir": "Нуар",
"Snow": "Снег",
"Current cockpit vision style: {style}. Activate for next style.": "Текущий стиль обзора кабины: {style}. Активируйте, чтобы переключить на следующий.",
"Current style: {style} — click for next": "Текущий стиль: {style} — нажмите для следующего",
"AIRCRAFT": "САМОЛЁТ",
@@ -553,8 +558,6 @@
"Expand Cockpit display options": "Развернуть параметры экрана кабины",
"Collapse Cockpit Radio controls": "Свернуть управление радио кабины",
"Expand Cockpit Radio controls": "Развернуть управление радио кабины",
"Close compact Radio controls": "Закрыть компактное управление радио",
"Open compact Radio controls": "Открыть компактное управление радио",
"DIRECTORY BAND": "ДИАПАЗОН КАТАЛОГА",
"{category} BAND": "ДИАПАЗОН {category}",
"NO STATION SELECTED": "СТАНЦИЯ НЕ ВЫБРАНА",
@@ -568,9 +571,6 @@
"selected": "выбранную",
"nearest": "ближайшую",
"{action} {target} radio station": "{action} {target} радиостанцию",
"RADIO STATE UNCERTAIN": "СОСТОЯНИЕ РАДИО НЕОПРЕДЕЛЁННО",
"SYNCING DIRECTORY": "СИНХРОНИЗАЦИЯ КАТАЛОГА",
"RADIO READY": "РАДИО ГОТОВО",
"SYNCING": "СИНХРОНИЗАЦИЯ",
"READY": "ГОТОВО",
"station": "станция",
@@ -690,7 +690,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": "Закрепить панель места",
@@ -710,9 +709,6 @@
"FLIR": "FLIR",
"Style": "Стиль",
"STATION SITE": "САЙТ СТАНЦИИ",
"Open detailed Radio controls": "Открыть подробное управление радио",
"Previous station": "Предыдущая станция",
"Next station": "Следующая станция",
"Expand panel": "Развернуть панель",
"CONTACTS": "КОНТАКТЫ",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Перебирает ближайшие контакты выбранного типа — самолёты, суда, объекты. Спутники отслеживаются отдельно.",
@@ -724,13 +720,11 @@
"Next filtered station": "Следующая отфильтрованная станция",
"STOP": "СТОП",
"Stop radio playback": "Остановить воспроизведение радио",
"Compact Radio controls": "Компактное управление радио",
"Context mode": "Режим контекста",
"Contact Context actions": "Действия контекста контакта",
"Available Space Missions": "Доступные космические миссии",
"LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ",
"Radio playback": "Воспроизведение радио",
"Compact Radio volume": "Громкость компактного радио",
"Radio volume": "Громкость радио",
"STATION TAG": "ТЕГ СТАНЦИИ",
"DRAG TO TUNE": "ТЯНИТЕ ДЛЯ НАСТРОЙКИ",
@@ -777,7 +771,6 @@
"Fade": "Затухание",
"Outside": "Снаружи",
"Tactical": "Тактический",
"Operator": "Операторский",
"Minimal": "Минимальный",
"HUD layout": "Макет HUD",
"Layout": "Макет",
@@ -2266,6 +2259,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}",
@@ -2486,8 +2481,6 @@
"Exit cockpit to fly to a route": "Выйдите из кабины, чтобы перелететь к маршруту",
"Exit cockpit to fly to a file": "Выйдите из кабины, чтобы перелететь к файлу",
"Exit cockpit to fly to a scene": "Выйдите из кабины, чтобы перелететь к сцене",
"Go to expanded Radio section": "Перейти к развёрнутому разделу радио",
"Expand Radio section in Context": "Развернуть раздел «Радио» в панели «Контекст»",
"New scene name": "Название новой сцены",
"Delete scene \"{scene}\" and all shots?": "Удалить сцену «{scene}» и все кадры?",
"Detailed outline unavailable": "Подробный контур недоступен",
File diff suppressed because it is too large. Load diff
+5 -15
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": "风场上游数据源不可用",
@@ -1551,6 +1553,9 @@
"{count} cameras loaded · enable CCTV to activate": {
"other": "已加载 {count} 个摄像头 · 启用 CCTV 以激活"
},
"{count} more cameras outside this view · pan or zoom out": {
"other": "此视图外还有 {count} 个摄像头 · 平移或缩小"
},
"Enable CCTV to load camera intersections": "启用 CCTV 以加载路口摄像头",
"Enable CCTV to start camera-linked intelligence summaries.": "启用 CCTV 以开始生成与摄像头关联的情报摘要。",
"No summary available.": "暂无摘要。",
@@ -1624,7 +1629,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": "收起目标面板",
@@ -1861,10 +1865,6 @@
"STATION UNAVAILABLE": "电台不可用",
"Station unavailable after directory refresh": "目录刷新后该电台不可用",
"Radio could not {action} cleanly": "电台未能正常{action}",
"Go to expanded Radio section": "前往展开的电台区域",
"Expand Radio section in Context": "在态势面板中展开电台区域",
"Close compact Radio controls": "关闭精简电台控件",
"Open compact Radio controls": "打开精简电台控件",
"SYNC": "同步",
"RECONCILE": "核对",
"DISABLE": "停用",
@@ -1886,9 +1886,6 @@
"selected": "所选",
"nearest": "最近",
"{action} {target} radio station": "{action}{target}电台",
"RADIO STATE UNCERTAIN": "电台状态不确定",
"SYNCING DIRECTORY": "正在同步目录",
"RADIO READY": "电台就绪",
"SYNCING": "同步中",
"READY": "就绪",
"station": "电台",
@@ -2536,7 +2533,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": "样式",
@@ -2552,11 +2548,6 @@
"RECENT IMAGERY": "近期影像",
"Imagery days in the box": "视野内的影像天数",
"CONTEXT": "态势",
"Compact Radio controls": "精简电台控件",
"Open detailed Radio controls": "打开详细电台控件",
"Previous station": "上一个电台",
"Next station": "下一个电台",
"Compact Radio volume": "精简电台音量",
"Expand panel": "展开面板",
"Context mode": "态势模式",
"CONTACTS": "目标",
@@ -2617,7 +2608,6 @@
"Layout": "布局",
"HUD layout": "HUD 布局",
"Tactical": "战术",
"Operator": "操作员",
"Minimal": "精简",
"Cyber": "赛博",
"Sonar": "声呐",
+5 -15
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": "風場上游數據源不可用",
@@ -1541,6 +1543,9 @@
"{count} cameras loaded · enable CCTV to activate": {
"other": "已加載 {count} 個攝像頭 · 啟用 CCTV 以激活"
},
"{count} more cameras outside this view · pan or zoom out": {
"other": "此視圖外還有 {count} 個攝像頭 · 平移或縮小"
},
"Enable CCTV to load camera intersections": "啟用 CCTV 以加載路口攝像頭",
"Enable CCTV to start camera-linked intelligence summaries.": "啟用 CCTV 以開始生成與攝像頭關聯的情報摘要。",
"No summary available.": "暫無摘要。",
@@ -1614,7 +1619,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": "收起目標面板",
@@ -1849,10 +1853,6 @@
"STATION UNAVAILABLE": "電臺不可用",
"Station unavailable after directory refresh": "目錄刷新後該電臺不可用",
"Radio could not {action} cleanly": "電臺未能正常{action}",
"Go to expanded Radio section": "前往展開的電臺區域",
"Expand Radio section in Context": "在態勢面板中展開電臺區域",
"Close compact Radio controls": "關閉精簡電臺控件",
"Open compact Radio controls": "打開精簡電臺控件",
"SYNC": "同步",
"RECONCILE": "核對",
"DISABLE": "停用",
@@ -1874,9 +1874,6 @@
"selected": "所選",
"nearest": "最近",
"{action} {target} radio station": "{action}{target}電臺",
"RADIO STATE UNCERTAIN": "電臺狀態不確定",
"SYNCING DIRECTORY": "正在同步目錄",
"RADIO READY": "電臺就緒",
"SYNCING": "同步中",
"READY": "就緒",
"station": "電臺",
@@ -2524,7 +2521,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": "樣式",
@@ -2540,11 +2536,6 @@
"RECENT IMAGERY": "近期影像",
"Imagery days in the box": "視野內的影像天數",
"CONTEXT": "態勢",
"Compact Radio controls": "精簡電臺控件",
"Open detailed Radio controls": "打開詳細電臺控件",
"Previous station": "上一個電臺",
"Next station": "下一個電臺",
"Compact Radio volume": "精簡電臺音量",
"Expand panel": "展開面板",
"Context mode": "態勢模式",
"CONTACTS": "目標",
@@ -2605,7 +2596,6 @@
"Layout": "佈局",
"HUD layout": "HUD 佈局",
"Tactical": "戰術",
"Operator": "操作員",
"Minimal": "精簡",
"Cyber": "賽博",
"Sonar": "聲吶",
+704
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",
@@ -125,6 +291,531 @@
"Δ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",
"AIS: --",
"AIS: {name}",
"BRG {value}°",
"BRG —",
"CAL {value}",
"CAL · {badge}",
"CCTV",
"CH {index} / {total}",
"CRS {value}°",
"CRT",
"FLIR",
"FLT",
"FM MHZ",
"FOV",
"FOV {value}°",
"FT",
"GEO",
"HDG",
"HDG {value}°",
"Ironbow",
"Landsat 8/9",
"MSG/S",
"NVG",
"POS: {time}Z",
"REC",
"S2 GhostMaps",
"SRC {value}",
"Sentinel-2",
"USB {rate} msg/s",
"WHOT/BHOT",
"dB",
"ft",
"hPa",
"km",
"km/h",
"kt",
"mph",
"{city} CCTV",
"{rate} msg/s",
"{source} HTTP {status}",
"{speed} kn",
"{type} SPD: {speed} HDG: {heading}",
"ΔE",
"Δ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}",
@@ -143,6 +834,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",
+3 -1
View File
@@ -17,6 +17,9 @@ test('Chinese HUD and cockpit units keep their case without spacing coordinates'
assert.equal(formatCockpitDistance(14000), '14\u00a0km');
assert.equal(LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 }),
'扇区 21.33N 77.50W');
setLocale('ja');
assert.equal(hudQuantity(13, 'km'), '13 km');
assert.equal(hudQuantity(1.5, 'km', 1), '1.5 km');
setLocale('en');
assert.equal(hudQuantity(0, 'km'), '0KM');
assert.equal(hudQuantity(2069, 'km', 1), '2069.0KM');
@@ -88,7 +91,6 @@ test('radio option text and accessible name use the same localized parentheses',
setLocale(locale);
const host = {
_radioPanel: element(), _radioFilter: element(),
_syncContextRadioLauncherState() {},
actions: { getLifecycle: () => null, scheduleLayout() {} },
};
renderRadioState.call(host, {
+1 -1
View File
@@ -48,7 +48,7 @@ test('setup placement moves the same button only in Cyber and disconnects on tea
chip,
);
assert.equal(chip.parentNode, home);
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
for (const variant of ['cyber', 'tactical', 'cyber', 'minimal']) {
theme.dataset.uiTheme = variant;
notify();
assert.equal(chip.parentNode, variant === 'cyber' ? toolbar : home);
+10 -3
View File
@@ -99,7 +99,8 @@ export function createCards({ state: layerState, services, parts, source }) {
const candidates = [];
const screenById = new Map();
for (const record of layerState._records) {
// Cards go only on the icons the view pass shows (refreshHorizonCulling).
for (const record of layerState._viewRecords) {
const id = record.camera.id;
if (id === activeId || !record.position) continue;
let inView = false;
@@ -187,7 +188,14 @@ export function createCards({ state: layerState, services, parts, source }) {
nowMs: Date.now(),
cardLimit: budgets.cardLimit,
});
layerState._cardIds = new Set(retention.keepIds);
// Grace keeps a card briefly after it loses selection, but never on a
// camera whose icon the view pass has hidden.
const shownIds = new Set(
layerState._viewRecords.map((record) => record.camera.id),
);
layerState._cardIds = new Set(
retention.keepIds.filter((id) => shownIds.has(id)),
);
layerState._cardGraceState = retention.graceState;
// Bounded thumbnail LRU: live cards (grace included) never lose their
@@ -371,7 +379,6 @@ export function createCards({ state: layerState, services, parts, source }) {
layerState._cctvOverlayHost.clearSource(CCTV_OVERLAY_SOURCE_ID);
layerState._cctvOverlayHost.setVisible(CCTV_OVERLAY_SOURCE_ID, false);
parts.hover.clearHoverCard();
layerState._cameraMoving = false;
layerState._cardIds = new Set();
layerState._cardGraceState = new Map();
layerState._cardFrameSlots = new Map();
+8 -9
View File
@@ -205,22 +205,21 @@ export function createControls({ state: layerState, services, parts, source }) {
* @returns {{ position: Cesium.Cartesian3, id: string, type: string }[]}
*/
getDetectableObjects(options = {}) {
if (!layerState._enabled || layerState._records.length === 0) return [];
// The cameras the view pass shows, not icons it has hidden.
const records = layerState._viewRecords;
if (!layerState._enabled || records.length === 0) return [];
const maxCount = Number.isFinite(options.maxCount)
? Math.max(1, Math.floor(options.maxCount))
: layerState._records.length;
: records.length;
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
const stride = Math.max(
1,
Math.ceil(layerState._records.length / maxCount),
);
const stride = Math.max(1, Math.ceil(records.length / maxCount));
const start = seed % stride;
const objects = [];
for (let i = start; i < layerState._records.length; i += stride) {
const camera = layerState._records[i].camera;
for (let i = start; i < records.length; i += stride) {
const camera = records[i].camera;
objects.push({
position: layerState._records[i].position,
position: records[i].position,
sourceId: camera.id,
// Short semantic code where the id is opaque (see cameraDisplayCode).
id: `CAM-${camera.code || camera.id}`,
+12
View File
@@ -581,6 +581,13 @@ export function createGeometry({ state: layerState, services, parts, source }) {
function coverageNeighborCount(targetRecord) {
if (!targetRecord) return 0;
// The summary asks on every notification; scan the catalog only when the
// camera, its pose or the catalog changed.
const { id, lat, lon, rangeM } = targetRecord.camera;
const key = `${id}|${lat}|${lon}|${rangeM}|${layerState._records.length}`;
const cached = layerState._neighborCountCache;
if (cached?.key === key && cached.records === layerState._records)
return cached.count;
let count = 0;
for (const record of layerState._records) {
if (record === targetRecord) continue;
@@ -594,6 +601,11 @@ export function createGeometry({ state: layerState, services, parts, source }) {
((targetRecord.camera.rangeM + record.camera.rangeM) / 1000) * 0.92;
if (dKm <= overlapKm) count++;
}
layerState._neighborCountCache = {
key,
records: layerState._records,
count,
};
return count;
}
+151 -17
View File
@@ -6,6 +6,8 @@ import {
GEO_TRACKING_BATCH_DELAY_MS,
GEO_LOAD_BATCH_SIZE,
GEO_LOAD_BATCH_DELAY_MS,
VIEW_GEOMETRY_ATTEMPTS,
VIEW_GEOMETRY_RETRY_MS,
} from './policy.js';
export function createGeometryQueue({
@@ -25,6 +27,8 @@ export function createGeometryQueue({
clearTimeout(layerState._geoQueueTimer);
layerState._geoQueueTimer = 0;
}
clearTimeout(layerState._viewGeometryRetryTimer);
layerState._viewGeometryRetryTimer = 0;
layerState._geoQueue = [];
layerState._geoProgressNotifier = null;
if (clearProgress) {
@@ -223,6 +227,7 @@ export function createGeometryQueue({
err?.message || err,
);
}
noteViewAttempt(record);
if (
layerState._geoLoading &&
layerState._geoLoadDone < layerState._geoLoadTotal
@@ -236,14 +241,15 @@ export function createGeometryQueue({
layerState._geoLoading = false;
if (wasInitialLoad) {
layerState._geoLoadDone = layerState._geoLoadTotal;
if (layerState._enabled) {
parts.rendering.refreshCoverageStyles();
// Geometry refinement may have replaced record.position objects — the
// one-shot drain completion re-anchors the card entries (event-driven,
// not a per-frame or timer pass).
parts.cards.refreshAmbientCards();
}
if (layerState._enabled) parts.rendering.refreshCoverageStyles();
}
// Geometry refinement may have replaced record.position objects — every
// drain's completion re-anchors the card entries (event-driven, not a
// per-frame or timer pass).
// While the camera moves, moveEnd reselects them instead.
if (layerState._enabled && !layerState._cameraMoving)
parts.cards.refreshAmbientCards();
scheduleViewGeometryRetry();
// Completion is never coalesced: subscribers must observe the final
// loading state even if the last progress tick just happened.
layerState._geoProgressNotifier?.finish();
@@ -261,16 +267,17 @@ export function createGeometryQueue({
/**
* Appends records to the geometry queue (no progress tracking) and starts
* the batch timer if idle. Used by update()'s ONE-SHOT tiles-ready completion
* pass (records left `!groundResolved` by an enable-time drain that ran while
* tiles were still streaming) so it shares the same stagger machinery as the
* initial load. Fires at most once per enable — never on a recurring timer.
* the batch timer if idle. Used by update()'s one-shot tiles-ready
* completion pass and by enqueueViewGeometry, so both share the initial
* load's stagger machinery. Never driven by a recurring timer.
* @param {Object[]} records - Camera records needing geometry refresh.
*/
function enqueueGeometryRefresh(records) {
const queued = new Set(layerState._geoQueue);
for (const record of records) {
if (!layerState._geoQueue.includes(record)) {
if (!queued.has(record)) {
queued.add(record);
layerState._geoQueue.push(record);
}
}
@@ -283,10 +290,10 @@ export function createGeometryQueue({
}
/**
* Starts the initial staggered load: orders all records active-camera-first,
* then by distance from the current viewer position (nearest first, so
* cameras likely in view refine before off-screen ones), and exposes
* loaded/total progress through uiState()/getStats() while running.
* Starts the initial staggered load over the geometry working set: active
* camera first, then by distance from the current viewer position (nearest
* first), and exposes loaded/total progress through uiState()/getStats()
* while running. Cameras outside the view refine when they come into it.
*/
function startGeometryLoadQueue() {
@@ -303,7 +310,7 @@ export function createGeometryQueue({
const refLon = carto
? Cesium.Math.toDegrees(carto.longitude)
: (active?.camera.lon ?? 0);
const pending = layerState._records
const pending = geometryWorkingSet()
.filter((record) => record !== active)
.map((record) => ({
record,
@@ -325,7 +332,134 @@ export function createGeometryQueue({
);
layerState._geoQueueTimer = setTimeout(processGeometryBatch, 0);
}
/**
* The records whose geometry the view needs: the shown icons, the active
* camera and the coverage cohort drawn around it.
* @returns {Object[]}
*/
function geometryWorkingSet() {
const records = new Set(layerState._viewRecords);
const active = parts.selection.getActiveRecord();
if (active) records.add(active);
for (const id of layerState._coverageVisibleIds) {
const record = layerState._recordById.get(id);
if (record) records.add(record);
}
return [...records];
}
/** The regime key a view attempt counts against: interim while tiles stream. */
function viewAttemptKey() {
const regime = parts.ground.currentSurfaceRegime();
const tilesReady =
regime !== 'google-3d' || parts.model.projectionTilesReady();
return { key: tilesReady ? regime : `${regime}:interim`, tilesReady };
}
/**
* Counts a processed geometry attempt against the current regime key; each
* key keeps its own count, so tile streaming going on and off does not
* reset the ready-tiles budget.
*/
function noteViewAttempt(record) {
const { key } = viewAttemptKey();
record.viewAttempts ??= {};
record.viewAttempts[key] = (record.viewAttempts[key] || 0) + 1;
record.viewAttemptAt = performance.now();
}
/**
* Whether a record may be attempted again under `key`. After a first
* unresolved attempt, the second waits out VIEW_GEOMETRY_RETRY_MS so its
* elevation cell can warm, rather than going to the next motion tick.
*/
/** Whether a record still has an attempt left under the current key. */
function withinViewBudget(record) {
const { key } = viewAttemptKey();
return (record.viewAttempts?.[key] || 0) < VIEW_GEOMETRY_ATTEMPTS;
}
function attemptsLeft(record, key) {
const attempts = record.viewAttempts?.[key] || 0;
if (attempts >= VIEW_GEOMETRY_ATTEMPTS) return false;
return (
attempts === 0 ||
performance.now() - record.viewAttemptAt >= VIEW_GEOMETRY_RETRY_MS
);
}
/**
* After a drain, gives working-set cameras it left ungrounded one more
* attempt once their elevation cells have had time to warm. The timer is
* set for the earliest such camera and re-arms for the rest when it fires.
*/
function scheduleViewGeometryRetry() {
if (!layerState._enabled || layerState._viewGeometryRetryTimer) return;
const { key } = viewAttemptKey();
const active = parts.selection.getActiveRecord();
let due = Infinity;
for (const record of geometryWorkingSet()) {
const attempts = record.viewAttempts?.[key] || 0;
if (
record === active ||
attempts === 0 ||
attempts >= VIEW_GEOMETRY_ATTEMPTS ||
!parts.ground.needsGeometry(record)
)
continue;
due = Math.min(due, record.viewAttemptAt + VIEW_GEOMETRY_RETRY_MS);
}
if (due === Infinity) return;
layerState._viewGeometryRetryTimer = setTimeout(
() => {
layerState._viewGeometryRetryTimer = 0;
enqueueViewGeometry();
// A drain re-arms on completion; with none running, the cameras still
// cooling down need their own timer.
if (!layerState._geoQueueTimer) scheduleViewGeometryRetry();
},
Math.max(0, due - performance.now()),
);
}
/**
* Queues working-set cameras the current surface has not grounded yet,
* after a camera move brings new ones into view. Attempts are counted when
* a record is processed, not queued, so a drain cut short by disable() is
* not lost; each record gets VIEW_GEOMETRY_ATTEMPTS per surface regime,
* spaced by VIEW_GEOMETRY_RETRY_MS, so a sample the shared floor refuses
* (too far, too high) is not retried on every move. While photoreal tiles are still streaming the attempts are
* interim, and the update() completion pass is re-armed to retry these
* records when they land. The active camera is left to selection, which
* owns its re-sampling.
*/
function enqueueViewGeometry() {
if (!layerState._enabled) return;
const { key, tilesReady } = viewAttemptKey();
const active = parts.selection.getActiveRecord();
const pending = [];
let coolingDown = false;
for (const record of geometryWorkingSet()) {
if (record === active || !parts.ground.needsGeometry(record)) continue;
if (!attemptsLeft(record, key)) {
coolingDown ||=
(record.viewAttempts?.[key] || 0) < VIEW_GEOMETRY_ATTEMPTS;
continue;
}
pending.push(record);
}
// A camera back in view during its cooldown still needs its retry timer.
if (coolingDown && !layerState._geoQueueTimer) scheduleViewGeometryRetry();
if (!pending.length) return;
if (!tilesReady) layerState._tilesReadyReenqueued = false;
enqueueGeometryRefresh(pending);
}
return {
enqueueViewGeometry,
withinViewBudget,
geometryWorkingSet,
stopGeometryLoadQueue,
createGeometryProgressNotifier,
processCctvGeometryQueueBatch,
+40 -3
View File
@@ -1,3 +1,4 @@
import * as Cesium from 'cesium';
import {
SUPPORT_KEYS,
planeSupportPoints,
@@ -78,6 +79,14 @@ export function createGround({ state: layerState, services, parts, source }) {
return record?.groundResolved?.[regime] === true;
}
/**
* True until the record has geometry grounded for the regime. Cameras are
* refined only once they enter the view, so many never have geometry.
*/
function needsGeometry(record, regime = currentSurfaceRegime()) {
return !record?.frustumGeometry || !isGroundResolved(record, regime);
}
/**
* Ground altitude used for pure geometry recomputes: the given regime's
* cached resolution (`record.groundSamples[regime]` — a shared mesh/DEM floor
@@ -184,8 +193,11 @@ export function createGround({ state: layerState, services, parts, source }) {
* Task 5: applies a LATE-arriving ground-prior batch (init's bounded race
* lost — cold proxy cache / slow upstream). Pure recomputes only, no scene
* queries:
* - records without geometry yet: only the icon moves onto the prior;
* the frustum is built when the camera enters the view.
* - terrain-globe regime: the prior IS the resolution → re-run the
* resolution (updateRecordGeometry latches it) for every record.
* resolution (updateRecordGeometry latches it) for every record with
* geometry.
* - google-3d regime: records still awaiting a shared floor move from the
* catalog fallback onto the exact prior; records already holding a shared
* mesh/DEM floor keep it untouched.
@@ -198,6 +210,7 @@ export function createGround({ state: layerState, services, parts, source }) {
function applyLateGroundPriors(records, priors) {
if (!Array.isArray(records) || !Array.isArray(priors)) return;
let applied = 0;
let moved = 0;
for (let i = 0; i < records.length && i < priors.length; i++) {
const record = records[i];
const prior = priors[i];
@@ -212,6 +225,24 @@ export function createGround({ state: layerState, services, parts, source }) {
record.camera.absoluteHeightM =
prior.ellipsoid + record.camera.mountHeightM;
}
// Cameras without geometry only move their icon onto the prior, so the
// view pass projects the right point; their frustums are built when
// they enter the view, as recomputing the whole catalog here would
// stall a large one.
if (!record.frustumGeometry) {
if (record.position) {
Cesium.Cartesian3.fromDegrees(
record.camera.lon,
record.camera.lat,
record.camera.absoluteHeightM,
undefined,
record.position,
);
if (record.billboard) record.billboard.position = record.position;
moved += 1;
}
continue;
}
const regime = currentSurfaceRegime();
if (regime === 'terrain-globe') {
// Prior IS the resolution — re-latch onto the fresh value.
@@ -224,7 +255,9 @@ export function createGround({ state: layerState, services, parts, source }) {
applied += 1;
}
}
if (applied) parts.presentation.notifyListeners();
if (moved && parts.rendering.refreshHorizonCulling())
parts.geometryQueue.enqueueViewGeometry();
if (applied || moved) parts.presentation.notifyListeners();
}
/**
@@ -234,7 +267,7 @@ export function createGround({ state: layerState, services, parts, source }) {
* deck/building-top in google-3d vs bare Re:Earth DEM on globe stacks), so
* on a REGIME change (photoreal ↔ globe; bing→osm stays 'terrain-globe' and
* no-ops):
* 1. every record's geometry recomputes IMMEDIATELY from the new regime's
* 1. every record that has geometry recomputes IMMEDIATELY from the new regime's
* resolution — cached sample if that regime has one, else the Re:Earth
* prior (never blank, zero scene queries);
* 2. entering google-3d re-arms the one-shot tiles-ready completion latch so
@@ -250,6 +283,9 @@ export function createGround({ state: layerState, services, parts, source }) {
layerState._lastAppliedRegime = regime;
for (const record of layerState._records) {
// Only cameras that already have geometry are rewritten; the rest are
// grounded for the new regime when they enter the view.
if (!record.frustumGeometry) continue;
if (regime === 'terrain-globe') {
// Prior IS the resolution — latch it (zero scene queries).
record.groundSamples['terrain-globe'] = groundPriorAltFor(record);
@@ -353,6 +389,7 @@ export function createGround({ state: layerState, services, parts, source }) {
resolveFootprintGround,
groundPriorAltFor,
isGroundResolved,
needsGeometry,
groundAltFor,
rearmGroundResolution,
resolveCommittedGroundAnchor,
+3
View File
@@ -91,6 +91,9 @@ export function createCctvLayer({ services, source }) {
processGeometryBatch: parts.geometryQueue.processGeometryBatch,
hideCctvRecordVisuals: parts.rendering.hideCctvRecordVisuals,
refreshCoverageStyles: parts.rendering.refreshCoverageStyles,
refreshHorizonCulling: parts.rendering.refreshHorizonCulling,
_viewRecordIdsForTest: () =>
state._viewRecords.map((record) => record.camera.id),
clearProbeClampOnDeactivation:
parts.geometry.clearProbeClampOnDeactivation,
cctvRecordNeedsActivation: parts.model.cctvRecordNeedsActivation,
+12 -3
View File
@@ -32,13 +32,22 @@ export function createIngestion({
// projectionTilesReady() is false while a (hidden) Google tileset
// exists, so this latch effectively fires for the google-3d regime —
// terrain-globe records resolve from the prior in their drain pass.
const unresolved = layerState._records.filter(
(record) => !parts.ground.isGroundResolved(record),
);
const unresolved = parts.geometryQueue
.geometryWorkingSet()
.filter(
(record) =>
!parts.ground.isGroundResolved(record) &&
parts.geometryQueue.withinViewBudget(record),
);
if (unresolved.length)
parts.geometryQueue.enqueueGeometryRefresh(unresolved);
}
await parts.health.syncHealthState();
// New health can change which camera wins a crowded cell.
if (layerState._enabled && parts.rendering.refreshHorizonCulling()) {
parts.geometryQueue.enqueueViewGeometry();
parts.cards.refreshAmbientCards();
}
parts.navigation.maybeAutoHop(now);
parts.presentation.notifyListeners();
},
+163 -3
View File
@@ -7,8 +7,14 @@ import {
CAMERA_ICON,
IDLE_CAMERA_COLOR,
CALIBRATION_RANGE_FLOOR_M,
VIEW_MOTION_REFRESH_MS,
VIEW_MOTION_MIN_MOVE_FRACTION,
VIEW_MOTION_MIN_TURN_DEG,
VIEW_MOTION_COST_FACTOR,
} from './policy.js';
const MIN_TURN_COS = Math.cos((VIEW_MOTION_MIN_TURN_DEG * Math.PI) / 180);
export function createLifecycle({
state: layerState,
services,
@@ -22,15 +28,94 @@ export function createLifecycle({
const { onFocusTargetAppear } = services.focus;
const { releaseContinuousRender } = services.render;
/**
* The settled-view pass: thin the icons, queue the newly visible geometry,
* reselect cards, and tell subscribers (the camera picker) when the shown
* set changed since they were last told, including by motion passes.
*/
function settleView() {
const changed = parts.rendering.refreshHorizonCulling();
noteViewPose();
parts.geometryQueue.enqueueViewGeometry();
parts.cards.refreshAmbientCards();
if (changed || layerState._viewUnpublished) {
layerState._viewUnpublished = false;
parts.presentation.notifyListeners();
}
}
/** Records the camera pose the last view pass ran at. */
function noteViewPose() {
const camera = layerState._viewer?.camera;
if (!camera?.positionWC) return;
layerState._viewPose = {
position: Cesium.Cartesian3.clone(camera.positionWC),
direction:
camera.directionWC && Cesium.Cartesian3.clone(camera.directionWC),
};
}
/**
* Whether the camera moved or turned enough since the last view pass to
* change what is on screen. A camera that tracks or flies without settling
* would otherwise rerun the full pass on every tick.
*/
function viewPoseChanged() {
const camera = layerState._viewer?.camera;
const pose = layerState._viewPose;
if (!pose || !camera?.positionWC) return true;
const height = Math.max(1, camera.positionCartographic?.height ?? 0);
const moved = Cesium.Cartesian3.distance(camera.positionWC, pose.position);
if (moved > height * VIEW_MOTION_MIN_MOVE_FRACTION) return true;
if (!pose.direction || !camera.directionWC) return false;
return (
Cesium.Cartesian3.dot(camera.directionWC, pose.direction) < MIN_TURN_COS
);
}
function viewMotionTick() {
if (!layerState._enabled) return;
const now = performance.now();
const pass = layerState._viewMotionPass;
if (pass && now - pass.at < pass.ms * VIEW_MOTION_COST_FACTOR) return;
if (!viewPoseChanged()) return;
if (parts.rendering.refreshHorizonCulling())
layerState._viewUnpublished = true;
noteViewPose();
parts.geometryQueue.enqueueViewGeometry();
layerState._viewMotionPass = { at: now, ms: performance.now() - now };
}
function startViewMotionTimer() {
if (layerState._viewMotionTimer) return;
layerState._viewMotionTimer = setInterval(
viewMotionTick,
VIEW_MOTION_REFRESH_MS,
);
}
function stopViewMotionTimer() {
clearInterval(layerState._viewMotionTimer);
layerState._viewMotionTimer = 0;
}
/** Resets all module-scoped runtime state to initial values. */
function clearRuntimeState() {
parts.geometryQueue.stopGeometryLoadQueue();
stopViewMotionTimer();
// Idempotent — also covers a re-init without a prior destroy().
parts.cards.teardownAmbientCards();
parts.projection.clearProjectionOverlay();
layerState._records = [];
layerState._recordById = new Map();
layerState._viewRecords = [];
layerState._cameraIndexCache = null;
layerState._coverageVisibleIds = new Set();
layerState._neighborCountCache = null;
layerState._viewUnpublished = false;
layerState._viewPose = null;
layerState._viewMotionPass = null;
layerState._healthById = new Map();
layerState._count = 0;
layerState._lastUpdate = null;
@@ -71,6 +156,13 @@ export function createLifecycle({
layerState._calibrationById = parts.calibration.loadCalibrationStore();
layerState._billboards = new Cesium.BillboardCollection();
// Cesium switches a property's buffer-usage hint whenever it starts or
// stops changing from one frame to the next, and every switch rewrites
// the vertex data of every billboard. The view pass toggles `show` on a
// few icons of a large catalog at a time, so on a 33K catalog each pass
// cost two full rewrites. With the hint fixed, Cesium uploads only the
// icons that changed.
layerState._billboards.computeNewBuffersUsage = () => false;
layerState._viewer.scene.primitives.add(layerState._billboards);
registerSpriteCollection('cctv', layerState._billboards);
@@ -243,28 +335,76 @@ export function createLifecycle({
// Field-test fix (2026-07-06): horizon-cull on camera settle (pairs with
// the billboards' always-on-top depth setting) + one initial pass so the
// first paint is already culled.
// A re-init against a new viewer moves the move listeners to its camera.
if (
layerState._viewListenerCamera &&
layerState._viewListenerCamera !== layerState._viewer.camera
) {
const previous = layerState._viewListenerCamera;
if (layerState._horizonCullListener)
previous.moveEnd?.removeEventListener(
layerState._horizonCullListener,
);
if (layerState._moveStartListener)
previous.moveStart?.removeEventListener(
layerState._moveStartListener,
);
layerState._horizonCullListener = null;
layerState._moveStartListener = null;
layerState._cameraMoving = false;
}
layerState._viewListenerCamera = layerState._viewer.camera;
if (!layerState._horizonCullListener) {
// Ambient cards piggyback the same settle event: moveEnd-driven
// reselection only, never per frame (refreshAmbientCards no-ops while
// the layer is disabled).
layerState._horizonCullListener = () => {
layerState._cameraMoving = false;
parts.rendering.refreshHorizonCulling();
parts.cards.refreshAmbientCards();
stopViewMotionTimer();
settleView();
};
layerState._viewer.camera.moveEnd.addEventListener(
layerState._horizonCullListener,
);
}
if (!layerState._moveStartListener) {
// Item B: hover picking pauses while the camera is in motion.
// Item B: hover picking pauses while the camera is in motion. A long
// move (cockpit flight, a drag) never settles, so the view pass also
// reruns on an interval until it does.
layerState._moveStartListener = () => {
layerState._cameraMoving = true;
// enable() starts it for a move already under way.
if (layerState._enabled) startViewMotionTimer();
};
layerState._viewer.camera.moveStart.addEventListener(
layerState._moveStartListener,
);
}
// A resize or rotation changes what is on screen without moving the
// camera.
const canvas = layerState._viewer.scene?.canvas;
if (layerState._viewResizeCanvas !== canvas) {
// A re-init against a new viewer observes its canvas instead.
layerState._viewResizeObserver?.disconnect();
layerState._viewResizeObserver = null;
layerState._viewResizeCanvas = null;
}
if (
!layerState._viewResizeObserver &&
typeof ResizeObserver === 'function' &&
canvas
) {
let frame = 0;
layerState._viewResizeObserver = new ResizeObserver(() => {
if (frame || !layerState._enabled) return;
frame = requestAnimationFrame(() => {
frame = 0;
if (layerState._enabled) settleView();
});
});
layerState._viewResizeObserver.observe(canvas);
layerState._viewResizeCanvas = canvas;
}
parts.rendering.refreshHorizonCulling();
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
@@ -325,6 +465,12 @@ export function createLifecycle({
);
await parts.health.syncHealthState(true);
// Health ranks crowded cells: thin again now that it has arrived (a
// no-op unless enable() ran while health was loading).
if (parts.rendering.refreshHorizonCulling()) {
parts.geometryQueue.enqueueViewGeometry();
parts.cards.refreshAmbientCards();
}
parts.rendering.refreshCoverageStyles();
parts.presentation.notifyListeners();
restoreSpriteOrder(layerState._viewer);
@@ -370,6 +516,9 @@ export function createLifecycle({
parts.projection.ensureProjectionRuntime(activeRecord);
parts.frames.refreshProjectionImage(activeRecord, true);
}
parts.rendering.refreshHorizonCulling();
// Re-enabled mid-flight: moveStart has already fired.
if (layerState._cameraMoving) startViewMotionTimer();
parts.geometryQueue.startGeometryLoadQueue();
parts.rendering.refreshCoverageStyles();
parts.projection.startProjectionLoop();
@@ -402,10 +551,14 @@ export function createLifecycle({
layerState._removeFocusAppearListener = null;
parts.projection.stopProjectionLoop();
parts.geometryQueue.stopGeometryLoadQueue();
stopViewMotionTimer();
// Ambient cards tear down COMPLETELY on disable (owner design point 6):
// source entries, pacer timer, in-flight handlers, and caches.
parts.cards.teardownAmbientCards();
parts.rendering.hideCctvVisuals();
// Nothing is in view while the layer is off.
layerState._viewRecords = [];
layerState._cameraIndexCache = null;
parts.presentation.notifyListeners();
},
@@ -443,6 +596,13 @@ export function createLifecycle({
);
layerState._moveStartListener = null;
}
// The move listeners are gone; nothing will report the end of a move.
layerState._cameraMoving = false;
layerState._viewListenerCamera = null;
stopViewMotionTimer();
layerState._viewResizeObserver?.disconnect();
layerState._viewResizeObserver = null;
layerState._viewResizeCanvas = null;
if (layerState._gizmo) {
layerState._gizmo.destroy();
layerState._gizmo = null;
+39
View File
@@ -60,6 +60,45 @@ export const GEO_PROGRESS_NOTIFY_INTERVAL_MS = 300;
export const GEO_PROGRESS_NOTIFY_BATCH_LIMIT = 10;
// View thinning: camera icons show only on or near the screen, and each
// camera competes for a world-anchored grid cell sized to about VIEW_CELL_PX
// at its distance from the viewer (in power-of-two metre steps, so a pan
// keeps the same winners until a camera's distance crosses a step). Cells
// smaller than VIEW_MIN_CELL_M do not thin:
// close up every camera shows, co-located ones included.
export const VIEW_CELL_PX = 40;
export const VIEW_MIN_CELL_M = 60;
// Icons this far past the screen edge (fraction of its size) still show, so
// a pan does not reveal empty ground before the next view pass.
export const VIEW_ICON_MARGIN = 0.5;
// While the camera moves, the view pass reruns at most this often.
export const VIEW_MOTION_REFRESH_MS = 250;
// A motion tick is skipped unless the camera moved this fraction of its
// ellipsoid height, or turned this many degrees, since the last pass.
export const VIEW_MOTION_MIN_MOVE_FRACTION = 0.01;
export const VIEW_MOTION_MIN_TURN_DEG = 0.5;
// Motion passes are spaced at least this many times the last pass's cost, so
// a large catalog spends at most about a tenth of a long move on them.
export const VIEW_MOTION_COST_FACTOR = 10;
// A view camera's geometry is attempted at most this many times per surface
// regime (a cold elevation cell can leave the first attempt unresolved); the
// retry runs this long after the drain that left it unresolved.
export const VIEW_GEOMETRY_ATTEMPTS = 2;
export const VIEW_GEOMETRY_RETRY_MS = 1500;
// Throttle for placeholder repaints — the projection RAF loop must not
// re-fill a 1080p canvas on every frame while a feed image is still loading.
@@ -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');
}
});
+102 -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,6 +8,59 @@ import {
isDatasheetFov,
} from './modelSpecs.js';
// 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' });
case 'city':
return t('CITY', { context: 'view band' });
case 'regional':
return t('REGIONAL', { context: 'view band' });
case 'global':
return t('GLOBAL', { context: 'view band' });
default:
return String(band || '').toUpperCase();
}
}
export function createPresentation({
state: layerState,
services,
@@ -72,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: viewBand.toUpperCase() }),
t('{value} CONTEXT', { value: summaryViewBandLabel(viewBand) }),
// Identified hardware: append the model's datasheet specs + attribution.
active.camera.spec
? t('{spec} · SPECS: CCTV-DATABASE.COM', {
@@ -225,14 +274,53 @@ export function createPresentation({
},
activeCameraId: activeId,
activeCamera: active ? getPublicCameraState(active, activeId) : null,
cameras: layerState._records.map((record) =>
getPublicCameraState(record, activeId),
),
cameraIndex: cameraIndex(),
summary: buildSummaryText(),
};
// The full per-camera state is built only when a subscriber reads it: a
// catalog of tens of thousands would otherwise be rebuilt on every
// progress tick for listeners that only want the active camera. It reads
// the records live at that moment, and spreading or serializing the
// payload builds it, so callers that want a count use `count`.
let cameras = null;
Object.defineProperty(payload, 'cameras', {
enumerable: true,
get() {
cameras ??= layerState._records.map((record) =>
getPublicCameraState(record, activeId),
);
return cameras;
},
});
return payload;
}
/**
* `{ id, city, name }` for the active camera, then every camera the view
* pass shows: what the camera picker lists. A whole catalog would put tens
* of thousands of options in the DOM and slow every layout. The same array
* is returned until the view or the active camera changes, so a consumer
* can compare it by identity.
* @returns {{id: string, city: string, name: string}[]}
*/
function cameraIndex() {
const view = layerState._viewRecords;
const active = parts.selection.getActiveRecord();
const cached = layerState._cameraIndexCache;
if (cached?.view === view && cached.active === active) return cached.list;
const records = active
? [active, ...view.filter((record) => record !== active)]
: view;
const list = records.map(({ camera }) => ({
id: camera.id,
city: camera.city,
name: camera.name,
}));
layerState._cameraIndexCache = { view, active, list };
return list;
}
/** Dispatches the current UI state to all registered subscriber callbacks. */
function notifyListeners() {
+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');
});
});
+189 -8
View File
@@ -1,5 +1,4 @@
import * as Cesium from 'cesium';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { isHeadingEstimated } from './headingConfidence.js';
import {
ACTIVE_CAMERA_COLOR,
@@ -11,8 +10,21 @@ import {
ACTIVE_COVERAGE_EDGE,
IDLE_COVERAGE_EDGE_MUTED,
ACTIVE_COVERAGE_EDGE_DEPTHFAIL,
VIEW_CELL_PX,
VIEW_ICON_MARGIN,
VIEW_MIN_CELL_M,
} from './policy.js';
/** FNV-1a over a camera id: a stable, well-spread order for crowded cells. */
function idHash(id) {
let hash = 0x811c9dc5;
for (let i = 0; i < id.length; i++) {
hash ^= id.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
export function createRendering({
state: layerState,
services,
@@ -138,24 +150,186 @@ export function createRendering({
* (Google-3D regime) nothing writes far-side depth, and the billboards are now
* always-on-top (`disableDepthTestDistance: INFINITY` — the far-zoom submerge
* fix), so without this pass London's cluster would shine through the planet
* from a US viewpoint. Pure math over ≤ catalog-size points; runs on
* camera.moveEnd + init only (event-driven — no steady-state work).
* from a US viewpoint.
*
* The same pass thins the view so a large catalog stays readable and cheap:
* icons well off screen hide, and each camera competes for a world-anchored
* cell sized to VIEW_CELL_PX at its distance (healthiest first, then a stable
* id order). The active camera shows whenever it is above the horizon
* and near the screen, even in a crowded cell. The shown records become
* `_viewRecords`, the working set for geometry, cards and the camera picker;
* the array keeps its identity while the shown set is unchanged.
*
* Runs on camera settle, throttled during motion, on resize, enable and
* activation changes — never per frame.
* @returns {boolean} Whether the shown set changed.
*/
function refreshHorizonCulling() {
if (
!layerState._enabled ||
!layerState._viewer ||
layerState._viewer.isDestroyed() ||
!layerState._records.length
)
return;
const occluder = horizonOccluder(layerState._viewer.camera);
return false;
const { camera, scene } = layerState._viewer;
const width = scene.canvas.clientWidth || scene.canvas.width || 0;
const height = scene.canvas.clientHeight || scene.canvas.height || 0;
const minX = -width * VIEW_ICON_MARGIN;
const maxX = width * (1 + VIEW_ICON_MARGIN);
const minY = -height * VIEW_ICON_MARGIN;
const maxY = height * (1 + VIEW_ICON_MARGIN);
// Ground metres one pixel covers per metre of distance from the viewer.
const fovy = Number.isFinite(camera.frustum?.fovy)
? camera.frustum.fovy
: Math.PI / 3;
const metresPerPixelPerMetre =
(2 * Math.tan(fovy / 2)) / Math.max(1, height);
const viewer = camera.positionWC;
// The horizon test of Cesium's EllipsoidalOccluder, inlined: this loop
// visits every camera in the catalog.
const inverseRadii = Cesium.Ellipsoid.WGS84.oneOverRadii;
const cx = viewer.x * inverseRadii.x;
const cy = viewer.y * inverseRadii.y;
const cz = viewer.z * inverseRadii.z;
const limbSquared = cx * cx + cy * cy + cz * cz - 1;
const activeId = layerState._activeCameraId;
const project = screenProjector(scene, camera, width, height);
const pass = (layerState._viewPassId = (layerState._viewPassId || 0) + 1);
const shown = [];
const cells = new Map();
for (const record of layerState._records) {
const position = record.billboard?.position;
if (!position) continue;
const vx = position.x * inverseRadii.x - cx;
const vy = position.y * inverseRadii.y - cy;
const vz = position.z * inverseRadii.z - cz;
const vtDotVc = -(vx * cx + vy * cy + vz * cz);
if (
limbSquared < 0
? vtDotVc > 0
: vtDotVc > limbSquared &&
(vtDotVc * vtDotVc) / (vx * vx + vy * vy + vz * vz) > limbSquared
)
continue;
const screen = project(position);
if (
!screen ||
!(screen.x >= minX && screen.x <= maxX) ||
!(screen.y >= minY && screen.y <= maxY)
)
continue;
const dx = position.x - viewer.x;
const dy = position.y - viewer.y;
const dz = position.z - viewer.z;
const cellM =
VIEW_CELL_PX *
metresPerPixelPerMetre *
Math.sqrt(dx * dx + dy * dy + dz * dz);
if (record.camera.id === activeId || !(cellM >= VIEW_MIN_CELL_M)) {
record.viewPass = pass;
shown.push(record);
continue;
}
const key = worldCellKey(record, cellM);
const rank = viewRank(record);
const holder = cells.get(key);
if (!holder || rank < holder.rank) cells.set(key, { record, rank });
}
for (const { record } of cells.values()) {
record.viewPass = pass;
shown.push(record);
}
for (const record of layerState._records) {
const bb = record.billboard;
if (!bb) continue;
const visible = occluder.isPointVisible(bb.position);
const visible = record.viewPass === pass;
if (bb.show !== visible) bb.show = visible;
}
const previous = layerState._viewRecords;
const changed =
previous.length !== shown.length ||
previous.some((record) => record.viewPass !== pass);
if (changed) layerState._viewRecords = shown;
return changed;
}
/**
* Canvas coordinates for world positions. In 3D the pass multiplies by one
* view-projection matrix per call instead of Cesium's per-point
* cartesianToCanvasCoordinates, which dominated the pass on a large
* catalog; other scene modes keep Cesium's projection.
* @returns {(position: Cesium.Cartesian3) => ({x: number, y: number}|null)}
*/
function screenProjector(scene, camera, width, height) {
const out = layerState._scratchViewScreen;
const projection = camera.frustum?.projectionMatrix;
if (
scene.mode !== Cesium.SceneMode.SCENE3D ||
!projection ||
!camera.viewMatrix
)
return (position) => scene.cartesianToCanvasCoordinates(position, out);
const m = Cesium.Matrix4.multiply(
projection,
camera.viewMatrix,
layerState._scratchViewProjection,
);
return (p) => {
const w = m[3] * p.x + m[7] * p.y + m[11] * p.z + m[15];
const z = m[2] * p.x + m[6] * p.y + m[10] * p.z + m[14];
// Behind the eye or before the near plane: Cesium does not draw it.
if (!(w > 0) || z < 0) return null;
out.x =
((m[0] * p.x + m[4] * p.y + m[8] * p.z + m[12]) / w + 1) * 0.5 * width;
out.y =
(1 - (m[1] * p.x + m[5] * p.y + m[9] * p.z + m[13]) / w) * 0.5 * height;
return out;
};
}
/**
* A grid cell fixed to the ground: `cellM` rounded up to a power of two
* metres, so the cell (and its winner) only changes when the camera's
* distance crosses a doubling: a level pan keeps it, a tilted pan or a zoom
* can move cameras near the edge of a step to the next one.
*/
function worldCellKey(record, cellM) {
const level = Math.ceil(Math.log2(cellM));
const { lat, lon } = record.camera;
// A camera's key changes only with the level (or an edited position), so
// it is kept per level.
if (
record.viewCellLevel === level &&
record.viewCellLat === lat &&
record.viewCellLon === lon
)
return record.viewCellKey;
const latStep = 2 ** level / 111_320;
const row = Math.floor(lat / latStep);
const rowLat = Cesium.Math.toRadians((row + 0.5) * latStep);
const lonStep = latStep / Math.max(0.05, Math.cos(rowLat));
record.viewCellLevel = level;
record.viewCellLat = lat;
record.viewCellLon = lon;
record.viewCellKey = `${level}:${row}:${Math.floor(lon / lonStep)}`;
return record.viewCellKey;
}
/** Lower ranks win a crowded cell: healthy feeds first, then a stable id order. */
function viewRank(record) {
const status = layerState._healthById.get(record.camera.id)?.status;
const tier =
status === 'ok'
? 0
: status === 'degraded'
? 1
: status === 'offline'
? 3
: 2;
record.viewHash ??= idHash(record.camera.id);
return tier * 2 ** 32 + record.viewHash;
}
/**
@@ -204,6 +378,8 @@ export function createRendering({
const activeId = activeRecord?.camera.id || null;
const coverageVisible =
parts.geometry.buildCoverageVisibleSet(activeRecord);
// Drawn coverage needs geometry too (geometryQueue's working set).
layerState._coverageVisibleIds = coverageVisible;
const coverageOn = layerState._coverageMode !== 'off';
const viewshedOn = layerState._coverageMode === 'viewshed';
if (coverageOn) {
@@ -215,8 +391,13 @@ export function createRendering({
for (const record of layerState._records) {
const isActive = record.camera.id === activeId;
if (record.billboard) {
record.billboard.color = cameraColorFor(record);
record.billboard.scale = isActive ? 1.25 : 1.0;
// Unchanged writes still dirty the collection, which then rewrites
// every icon's vertices: across a large catalog, write only changes.
const color = cameraColorFor(record);
if (!Cesium.Color.equals(record.billboard.color, color))
record.billboard.color = color;
const scale = isActive ? 1.25 : 1.0;
if (record.billboard.scale !== scale) record.billboard.scale = scale;
// disableDepthTestDistance stays POSITIVE_INFINITY for every billboard
// (set at creation) — see the field-test far-zoom submerge fix there.
}
+7
View File
@@ -141,6 +141,11 @@ export function createSelection({
void parts.ground.resolveFootprintGround(record);
record.activationDone = true;
parts.rendering.refreshCoverageStyles();
// The active camera's icon always shows; the previously active one
// competes for its cell again. Its coverage neighbours join the working
// set and are queued for grounding.
parts.rendering.refreshHorizonCulling();
parts.geometryQueue.enqueueViewGeometry();
// The newly active camera leaves the ambient ring (its monitor plane takes
// over); the freed slot re-fills on this same pass.
parts.cards.refreshAmbientCards();
@@ -171,7 +176,9 @@ export function createSelection({
parts.ground.groundAltFor(previous),
);
});
parts.rendering.refreshHorizonCulling();
parts.rendering.refreshCoverageStyles();
parts.geometryQueue.enqueueViewGeometry();
parts.cards.refreshAmbientCards();
layerState._gizmo?.refresh();
parts.presentation.notifyListeners();
+57
View File
@@ -76,6 +76,63 @@ export function createState({ services }) {
layerState._scratchFocusScreen = new Cesium.Cartesian2();
layerState._scratchViewScreen = new Cesium.Cartesian2();
layerState._scratchViewProjection = new Cesium.Matrix4();
// Counts view passes; a record shown by the latest pass carries its number.
layerState._viewPassId = 0;
// Records whose icons the last view pass showed (see refreshHorizonCulling):
// geometry refinement, the tiles-ready completion pass and ambient cards
// work over these, not the whole catalog.
layerState._viewRecords = [];
// Coverage ids refreshCoverageStyles last drew: part of the geometry
// working set even when their icons are thinned out.
layerState._coverageVisibleIds = new Set();
// View pass triggers besides camera settle: an interval while the camera
// moves, and a canvas resize observer.
layerState._viewMotionTimer = 0;
layerState._viewResizeObserver = null;
layerState._viewResizeCanvas = null;
// The camera the moveStart/moveEnd listeners are attached to.
layerState._viewListenerCamera = null;
// A motion pass changed the shown set and the settle pass has not yet told
// subscribers; the camera pose the last view pass ran at.
layerState._viewUnpublished = false;
layerState._viewPose = null;
// { at, ms } — when the last motion pass ran and what it cost.
layerState._viewMotionPass = null;
// Retries the view geometry a drain left unresolved, once cells warm.
layerState._viewGeometryRetryTimer = 0;
// { key, count } — the active camera's coverage-overlap count, which the
// summary reads on every notification.
layerState._neighborCountCache = null;
// { view, active, list } — the camera picker's index uiState publishes,
// rebuilt only when the view pass or the active camera changes.
layerState._cameraIndexCache = null;
// Staggered geometry-load queue state (see startGeometryLoadQueue).
layerState._geoQueue = [];
+7
View File
@@ -79,6 +79,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
* @param {boolean} [options.enabled=true] Layer enabled state.
* @param {'off'|'on'|'viewshed'} [options.coverageMode='on'] Coverage mode.
* @param {boolean} [options.showProjection=false] Projection visibility.
* @param {Map<string, Object>} [options.healthById] Health rows by camera id.
* @returns {void}
*/
@@ -89,6 +90,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
enabled = true,
coverageMode = 'on',
showProjection = false,
healthById = new Map(),
} = {}) {
layerState._viewer = viewer;
layerState._records = Array.isArray(records) ? records : [];
@@ -108,6 +110,11 @@ export function createTesting({ state: layerState, services, parts, source }) {
'on',
);
layerState._showProjection = !!showProjection;
layerState._healthById = healthById;
layerState._viewRecords = [];
layerState._cameraIndexCache = null;
layerState._coverageVisibleIds = new Set();
layerState._neighborCountCache = null;
}
return {
_pushAmbientCardEntriesForTest,
+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 {
+26 -4
View File
@@ -18,9 +18,19 @@
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 } from '../../ui/unitLabels.js';
import {
formatQuantity,
quantitySeparator,
unitLabel,
} from '../../ui/unitLabels.js';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
@@ -125,7 +135,9 @@ export function routeDurationText(seconds) {
if (minutes < 60) return formatQuantity(minutes, 'min');
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
const rest = minutes % 60;
return rest ? `${hours}\u00a0${formatQuantity(rest, 'min')}` : hours;
if (!rest) return hours;
// "1 h 5 min"; Japanese runs the parts together: 1時間5分.
return `${hours}${quantitySeparator(unitLabel('h'))}${formatQuantity(rest, 'min')}`;
}
/** "left", "slightly right", … as it reads inside a maneuver sentence. */
@@ -394,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
@@ -409,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 },
}));
-1
View File
@@ -114,7 +114,6 @@ export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([
'.hud-top-right',
'.hud-bottom-left',
'.hud-bottom-right',
'.hud-top-bar',
'.hud-bottom-bar',
'#space-mission-panel',
'#space-mission-panel-host',
-5
View File
@@ -166,11 +166,6 @@ test('Cockpit utility Escape leaves an expanded nested Parameters panel to the s
/const kind = displayOpen \? 'display' : 'radio';[\s\S]*?escapedFromDisclosure[\s\S]*?returnFocus: !escapedFromDisclosure[\s\S]*?disclosure\?\.blur/,
'Cockpit utility disclosures clear their own focus when Escape closes them',
);
assert.match(
radioBindings,
/_contextRadioDock\?\.classList\.contains\('disclosure-open'\)[\s\S]*?escapedFromDisclosure[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: !escapedFromDisclosure \}\)[\s\S]*?_contextRadioToggleBtn\?\.blur/,
'compact Radio disclosure clears its own focus when Escape closes it',
);
});
+27 -60
View File
@@ -206,10 +206,11 @@ test('no unchanged Realtime tool definition drifts silently', () => {
.sort((a, b) => a.name.localeCompare(b.name))
.map((tool) => structuredClone(tool))
.filter((tool) => tool.name !== 'set_cyber_sonar');
// Cyber adds one HUD choice and the sonar tool; retain the existing pin for every legacy field.
// Cyber adds one HUD choice and the sonar tool, and Operator was retired;
// retain the existing pin for every legacy field.
const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
assert.deepEqual(hudLayout.enum, ['tactical', 'minimal', 'cyber']);
hudLayout.enum = ['tactical', 'operator', 'minimal'];
// Globe rotation adds one motion and clarifies the two affected descriptions.
// Check those edits explicitly, then keep the legacy pin for every other field.
const camera = unchanged.find((tool) => tool.name === 'move_camera');
@@ -239,6 +240,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))
@@ -249,7 +263,7 @@ test('no unchanged Realtime tool definition drifts silently', () => {
});
test('Radio volume and mission speed share the Sharpen slider visual language', () => {
for (const id of ['cockpit-radio-volume', 'context-radio-mini-volume', 'radio-volume', 'sdr-volume']) {
for (const id of ['cockpit-radio-volume', 'radio-volume', 'sdr-volume']) {
assert.match(
html,
new RegExp(`id="${id}"[^>]*class="gev-quantitative-slider"[^>]*type="range"`),
@@ -269,22 +283,17 @@ test('Radio volume and mission speed share the Sharpen slider visual language',
assert.doesNotMatch(css, /#space-mission-panel \[data-mission-replay-speed\]::-webkit-slider-thumb/);
});
test('Radio is nested inside Context with separate disclosure and power controls', () => {
test('Radio is nested inside Context with its own disclosure and power controls', () => {
const contextStart = html.indexOf('id="global-context-panel"');
const radioStart = html.indexOf('id="radio-panel"');
const contextEnd = html.indexOf('\n </aside>', contextStart);
assert.ok(contextStart >= 0 && radioStart > contextStart && radioStart < contextEnd);
assert.match(html, /id="radio-panel"[^>]*data-panel-id="radio-panel"/);
assert.match(html, /aria-label="Radio playback"/);
assert.match(html, /id="context-radio-toggle-btn"[^>]*aria-expanded="false"[^>]*aria-controls="context-radio-mini"/);
assert.doesNotMatch(html, /id="context-radio-toggle-btn"[^>]*aria-pressed=/);
assert.match(html, /id="context-radio-mini"[^>]*aria-label="Compact Radio controls"[^>]*hidden/);
assert.match(html, /id="context-radio-mini-enable-btn"[^>]*aria-pressed="false"/);
assert.match(html, /id="context-radio-details-btn"[^>]*aria-expanded="false"[^>]*aria-controls="radio-panel"/);
assert.match(html, /id="context-radio-details-btn"[\s\S]*?<span class="material-symbols-outlined" aria-hidden="true">open_in_full<\/span>/);
assert.match(html, /id="context-radio-mini-close-btn"[^>]*aria-label="Close compact Radio controls"/);
assert.match(html, /id="context-radio-mini-(?:prev|play|next)-btn"/);
assert.match(html, /id="context-radio-mini-volume"/);
// The Context header carries no Radio shortcut; Radio lives in its own
// section below the modes, with one power control.
assert.doesNotMatch(html, /context-radio-/);
assert.match(html, /id="radio-enable-btn"[^>]*aria-pressed="false"/);
assert.match(html, /id="cockpit-radio-panel"[^>]*aria-label="Cockpit compact Radio controls"[^>]*hidden/);
assert.match(html, /id="cockpit-radio-enable-btn"[^>]*aria-pressed="false"/);
assert.match(html, /id="cockpit-radio-(?:prev|play|next)-btn"/);
@@ -329,8 +338,7 @@ test('Radio is nested inside Context with separate disclosure and power controls
const baseCss = css.replace(readFileSync(new URL('./ui/styles/cyber.css', import.meta.url), 'utf8'), '');
assert.doesNotMatch(baseCss, /#right-context-rail\s*>\s*#radio-panel/);
assert.match(css, /#global-context-panel #radio-panel\.collapsed/);
assert.doesNotMatch(css, /\.context-radio-dock\.active:hover \.context-radio-mini/);
assert.doesNotMatch(css, /\.context-radio-dock\.active:focus-within \.context-radio-mini/);
assert.doesNotMatch(css, /\.context-radio-/);
assert.match(css, /#right-context-rail #global-context-panel:not\(\.collapsed\) \.context-mode-view,[\s\S]*?#right-context-rail #global-context-panel:not\(\.collapsed\) #radio-panel\s*\{[\s\S]*?flex: 0 0 auto;/);
});
@@ -348,59 +356,18 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
assert.deepEqual(realtimeTools().find(tool => tool.name === 'control_radio').parameters.properties.action.enum, ['enable', 'disable', 'play', 'resume', 'pause', 'stop', 'next', 'previous', 'volume', 'select', 'status']);
const enableMethod = radioBindings;
assert.doesNotMatch(enableMethod, /playSelectedRadio|togglePlayback\(\).*radio-enable/i);
assert.match(enableMethod, /contextRadioToggleBtn/);
assert.match(enableMethod, /contextRadioMiniEnableBtn/);
assert.match(enableMethod, /contextRadioDetailsBtn/);
assert.match(enableMethod, /contextRadioMiniCloseBtn/);
assert.match(enableMethod, /contextRadioMiniPlayBtn/);
const disclosureStart = enableMethod.indexOf('this.listen(this._contextRadioToggleBtn,');
const disclosureEnd = enableMethod.indexOf("this.listen(this._radioFilter,", disclosureStart);
const disclosureBindings = enableMethod.slice(disclosureStart, disclosureEnd);
assert.ok(disclosureStart >= 0 && disclosureEnd > disclosureStart, 'compact Radio disclosure bindings are missing');
assert.doesNotMatch(disclosureBindings, /toggleRadio\(this\._contextRadioToggleBtn\)/);
assert.match(enableMethod, /toggleRadio\(this\._contextRadioMiniEnableBtn\)/);
assert.match(disclosureBindings, /contextRadioMiniCloseBtn[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: true \}\)/);
assert.match(disclosureBindings, /contextRadioDetailsBtn[\s\S]*?setPanelCollapsed\('radio-panel', false, \{ explicit: true \}\)/);
assert.match(
disclosureBindings,
/contextRadioToggleBtn[\s\S]*?!contextPanel\.classList\.contains\('collapsed'\)[\s\S]*?setRadioDisclosure\(false\)[\s\S]*?setPanelCollapsed\('radio-panel', false, \{ explicit: true \}\)[\s\S]*?_revealRadioPanelInsideContext/,
);
assert.doesNotMatch(disclosureBindings, /setPanelCollapsed\('radio-panel', !/);
assert.doesNotMatch(enableMethod, /contextRadio/);
assert.match(enableMethod, /toggleRadio\(this\._radioEnableBtn\)/);
assert.match(ui, /setAttribute\('aria-expanded', String\(/);
assert.match(readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8'), /\.hidden = !/);
assert.doesNotMatch(method, /panelId === 'global-context-panel'[\s\S]*?this\._radioState\?\.enabled[\s\S]*?setPanelCollapsed\('radio-panel', false\)/);
});
test('expanded Context routes its Radio icon to the embedded section and keeps collapsed Context compact', () => {
const revealStart = radioControlsSource.indexOf('\n async _revealRadioPanelInsideContext');
const revealEnd = radioControlsSource.indexOf('\n /**', revealStart + 10);
const revealMethod = radioControlsSource.slice(revealStart, revealEnd);
assert.ok(revealStart >= 0 && revealEnd > revealStart, 'embedded Radio reveal helper is missing');
assert.match(revealMethod, /requestAnimationFrame\(\(\) => requestAnimationFrame/);
assert.match(revealMethod, /scroller\.scrollTo\(\{\s*top: next,\s*behavior: reducedMotion \? 'auto' : 'smooth',?\s*\}\)/);
assert.match(revealMethod, /focus\?\.\(\{ preventScroll: true \}\)/);
assert.doesNotMatch(revealMethod, /setEnabled|togglePlayback|selectStation|setContextMode/);
const syncStart = radioControlsSource.indexOf('\n _syncContextRadioLauncherState()');
const syncEnd = radioControlsSource.indexOf('\n destroy()', syncStart + 10);
const syncMethod = radioControlsSource.slice(syncStart, syncEnd);
assert.ok(syncStart >= 0 && syncEnd > syncStart, 'Context Radio launcher state sync is missing');
assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded',\s*String\(radioExpanded\)/);
assert.match(syncMethod, /aria-controls',\s*'context-radio-mini'[\s\S]*?aria-expanded',\s*String\(compactOpen\)/);
const renderMethod = radioPresentation;
assert.match(renderMethod, /this\._syncContextRadioLauncherState\(\)/);
assert.doesNotMatch(renderMethod, /compact Radio controls/);
assert.doesNotMatch(renderMethod, /_contextRadioToggleBtn\.setAttribute\('aria-(?:controls|expanded|label)'/);
});
test('Radio disclosure is explicit, starts closed while off, and preserves playback state', () => {
const renderMethod = radioPresentation;
assert.doesNotMatch(renderMethod, /setPanelCollapsed\('radio-panel', true\).*stopPlayback/s);
assert.doesNotMatch(renderMethod, /_radioMiniExpanded\s*=\s*false.*audioState === 'playing'/s);
assert.match(ui, /contextRadioDetailsBtn/);
const syncStart = ui.indexOf('\n _syncPanelCollapseButton(panelEl)');
const syncMethod = ui.slice(syncStart, ui.indexOf('\n }', syncStart + 10));
assert.doesNotMatch(syncMethod, /contextRadioDetailsBtn[\s\S]*?(?:aria-label|textContent|\.title)/);
assert.doesNotMatch(renderMethod, /_syncContextRadioLauncherState/);
});
test('successful explicit user playback hands the speaker from voice to Radio', () => {
@@ -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',
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more