mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge branch 'main' into fix/e-local-adsb-geom-rate
This commit is contained in:
commit
780d8903ee
810 files changed
+70590
-21526
No files matched your search
@@ -256,6 +256,8 @@ AISSTREAM_API_KEY=
|
|||||||
# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras
|
# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras
|
||||||
# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required
|
# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required
|
||||||
# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL
|
# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL
|
||||||
|
# CCTV_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams
|
||||||
|
# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL
|
||||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||||
|
|||||||
@@ -20,3 +20,8 @@ node_modules
|
|||||||
.vite
|
.vite
|
||||||
.vite-*
|
.vite-*
|
||||||
*.min.js
|
*.min.js
|
||||||
|
|
||||||
|
# Application views, and panels lifted from them, hold the page markup
|
||||||
|
# verbatim; reflowing it changes the DOM
|
||||||
|
src/ui/views/*View.vue
|
||||||
|
src/ui/views/panels/
|
||||||
+7
-1
@@ -2,5 +2,11 @@
|
|||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"endOfLine": "lf"
|
"endOfLine": "lf",
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.vue",
|
||||||
|
"options": { "htmlWhitespaceSensitivity": "strict" }
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
+58
-2
@@ -115,7 +115,7 @@ The highest-leverage places to jump in:
|
|||||||
|
|
||||||
## Architecture in one minute
|
## Architecture in one minute
|
||||||
|
|
||||||
- **No framework.** Vanilla JS + [CesiumJS](https://cesium.com/platform/cesiumjs/) + [Vite](https://vitejs.dev/).
|
- **Vue 3 + Pinia for the interface,** [CesiumJS](https://cesium.com/platform/cesiumjs/) for the globe and [Vite](https://vitejs.dev/) to build. See [Building UI](#building-ui).
|
||||||
- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/data/<layer>.js` entries retain compatibility; new code belongs with its focused owner.
|
- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/data/<layer>.js` entries retain compatibility; new code belongs with its focused owner.
|
||||||
- Sources acquire records; renderers own Cesium resources. Import `gods-eye-view/layers/<family>/source` when only a source factory is needed. Common voice controls consume the session interface; protocol adapters own connection details.
|
- Sources acquire records; renderers own Cesium resources. Import `gods-eye-view/layers/<family>/source` when only a source factory is needed. Common voice controls consume the session interface; protocol adapters own connection details.
|
||||||
- **Secrets stay server-side.** Anything needing a private key goes through a local proxy under `server/providers/`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens.
|
- **Secrets stay server-side.** Anything needing a private key goes through a local proxy under `server/providers/`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens.
|
||||||
@@ -126,9 +126,65 @@ The highest-leverage places to jump in:
|
|||||||
- ES modules, **2-space indent, single quotes, semicolons.**
|
- ES modules, **2-space indent, single quotes, semicolons.**
|
||||||
- JSDoc on exported/public functions.
|
- JSDoc on exported/public functions.
|
||||||
- Match the surrounding code — comment density, naming, and idiom.
|
- Match the surrounding code — comment density, naming, and idiom.
|
||||||
- User-visible text goes through `t()`, `tn()` or `msg()`, or a `data-i18n*` attribute in markup, and `npm run i18n` must pass. See [docs/I18N.md](docs/I18N.md).
|
- User-visible text goes through `t()` or `tn()`, with `msg()` marking English that is stored and translated later. Components call `t()` in their templates, so their text follows the locale; `data-i18n*` attributes are only for static page markup such as the loading screen, and `npm run i18n` rejects them in a template. `npm run i18n` must pass. See [docs/I18N.md](docs/I18N.md).
|
||||||
- Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required.
|
- Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required.
|
||||||
|
|
||||||
|
## Building UI
|
||||||
|
|
||||||
|
New UX elements are Vue components whose state lives in a Pinia store, and so
|
||||||
|
are existing ones when a change converts them:
|
||||||
|
|
||||||
|
- **Views are Vue components.** Application views live in `src/ui/views/` and
|
||||||
|
the components they place in `src/ui/views/panels/`; a component that
|
||||||
|
belongs to one feature sits beside its owner (`src/ui/rail/`,
|
||||||
|
`src/ui/weather/`). A view renders what its store holds and forwards
|
||||||
|
clicks and input to the store; it holds no application logic.
|
||||||
|
- **State lives in a Pinia store** in `src/ui/stores/`. The owner presents a
|
||||||
|
plain view object and publishes it to the store; the view renders it. User
|
||||||
|
actions return to the owner through handlers it connects to the store,
|
||||||
|
which replace an earlier owner's. Where several owners answer different
|
||||||
|
actions of one store, use `createOwners()` from `src/ui/stores/owners.js`:
|
||||||
|
`act(name, ...args)` calls the newest connected owner that handles `name`,
|
||||||
|
and the owner calls `detach(token)` when it stops.
|
||||||
|
- **Portable owners stay framework-free.** An owner in a portable export
|
||||||
|
imports neither Vue nor Pinia: it takes a `publish` option or offers a
|
||||||
|
`subscribe…()`, and the application shell connects it to the store.
|
||||||
|
`npm run check:boundaries` enforces this.
|
||||||
|
- **Reuse the kit first.** `src/ui/kit/` has one component per control:
|
||||||
|
`UiButton` (including pressed toggles and pressable chips), `UiSelect`,
|
||||||
|
`UiSlider`, `UiSegmented`, `UiChip` (a label, not a control), `UiBadge`,
|
||||||
|
`UiEmpty`, `UiIcon`, `UiTooltip`, `UiPanel` and `UiRail`. Use these before
|
||||||
|
writing a control. When the kit lacks something, extend a kit component or
|
||||||
|
add one that follows the kit's names, props, events and class names (see
|
||||||
|
[Component kit](docs/VUE-MIGRATION.md#component-kit)), rather than
|
||||||
|
building a one-off.
|
||||||
|
- **No hand-written DOM binding.** Don't build elements or write text,
|
||||||
|
classes or attributes from a controller to show UI state; render it from
|
||||||
|
the store. New controls take input through the store rather than through
|
||||||
|
listeners the owner adds. To render outside the view's host (the body, the
|
||||||
|
globe container, or a slot another surface opens), use `<Teleport>` to
|
||||||
|
`body`, a fixed id or a container the owner publishes; don't move nodes by
|
||||||
|
hand. What stays imperative: classes on `<body>` and `<head>`, which sit
|
||||||
|
outside Vue (`cockpit-mode`, `ui-clean-view`); focus moves; positions a
|
||||||
|
layout pass measures in the same task that writes them; and surfaces an
|
||||||
|
owner paints or plays, such as the Cesium canvas, overlay canvases and live
|
||||||
|
video. A few existing surfaces keep listeners or bindings that
|
||||||
|
[docs/VUE-MIGRATION.md](docs/VUE-MIGRATION.md) records; keep those
|
||||||
|
contracts when you change them, and don't copy them into new UI.
|
||||||
|
- **Text** goes through `t()` or `tn()` where the template renders it.
|
||||||
|
English kept in a constant or a store is marked with `msg()` and passed to
|
||||||
|
`t()` when it renders, since a `t()` at module load would fix the English.
|
||||||
|
- **Test components, not markup strings.** Mount the component over
|
||||||
|
happy-dom with `@vue/test-utils` or `createApp` and assert what it renders
|
||||||
|
from its store (see `src/testSupport/vueDom.mjs`, `src/ui/kit/*.test.mjs`
|
||||||
|
and `src/ui/views/intelHudView.test.mjs`). Run one such file with
|
||||||
|
`node --import=./build/registerVue.js --test <file>`. Don't pass happy-dom
|
||||||
|
nodes to `assert.equal`/`deepEqual`; compare them with `assertSameNode()`
|
||||||
|
from `src/testSupport/domAssert.mjs`.
|
||||||
|
|
||||||
|
[docs/VUE-MIGRATION.md](docs/VUE-MIGRATION.md) records how each surface moved
|
||||||
|
and what stays imperative, and why.
|
||||||
|
|
||||||
## Formatting and reusable components
|
## Formatting and reusable components
|
||||||
|
|
||||||
Run `npm run format` before submitting changes, then `npm run format:check`
|
Run `npm run format` before submitting changes, then `npm run format:check`
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ How to read this:
|
|||||||
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
|
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
|
||||||
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
|
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
|
||||||
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
|
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
|
||||||
|
| **Autostrada del Brennero (A22)** (catalog `tourism.api.opendatahub.com`, frames `www.autobrennero.it/WebCamImg/`) | CCTV camera catalog + frames, A22 Brenner–Modena motorway, Italy | Public Autobrennero webcam images; camera list republished by the Open Data Hub, whose records are tagged CC0 (`ClosedData: false`, licence holder Autobrennero); Autobrennero publishes no terms of its own for the images | "Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)" (courtesy) |
|
||||||
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
||||||
| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" |
|
| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" |
|
||||||
| **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" |
|
| **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" |
|
||||||
@@ -176,6 +177,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
|||||||
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover.
|
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover.
|
||||||
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover.
|
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover.
|
||||||
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
|
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
|
||||||
|
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`.
|
||||||
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
|
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
|
||||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
|
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
|
||||||
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||||
|
|||||||
@@ -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.
|
- **🎯 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.
|
- **🖊️ 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.
|
- **🛫 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.
|
- **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view.
|
||||||
- **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry.
|
- **🎖️ 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.
|
- **🌐 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
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_
|
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."_
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -399,7 +399,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
|||||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||||
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
|
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
|
||||||
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
||||||
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
||||||
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
||||||
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
||||||
@@ -491,12 +491,12 @@ How the globe handles live data:
|
|||||||
- **Sits on the real ground.** Entity heights are aligned to work with Google 3D tiles, so aircraft park on aprons and cameras stand on street corners instead of floating.
|
- **Sits on the real ground.** Entity heights are aligned to work with Google 3D tiles, so aircraft park on aprons and cameras stand on street corners instead of floating.
|
||||||
- **Caching and request budgets.** An OpenSky credit governor, a TomTom daily tile budget, and disk-cached TLEs reduce repeated requests. These controls do not replace provider quotas or billing controls.
|
- **Caching and request budgets.** An OpenSky credit governor, a TomTom daily tile budget, and disk-cached TLEs reduce repeated requests. These controls do not replace provider quotas or billing controls.
|
||||||
- **Server-side credentials.** Every API that touches a private key (OpenAI, AISStream, OpenSky OAuth, camera frames) is brokered through a hardened server-side proxy with SSRF protection, response caps, and sanitized errors. The only keys the browser sees are Google Maps and Cesium ion (restrict both at the provider).
|
- **Server-side credentials.** Every API that touches a private key (OpenAI, AISStream, OpenSky OAuth, camera frames) is brokered through a hardened server-side proxy with SSRF protection, response caps, and sanitized errors. The only keys the browser sees are Google Maps and Cesium ion (restrict both at the provider).
|
||||||
- **No framework.** Vanilla JavaScript, **CesiumJS**, and **Vite** — plus **Google Photorealistic 3D Tiles** for the planet and the **OpenAI Realtime API** for voice. Fast to read, fast to hack on.
|
- **A small stack.** **Vue 3** and **Pinia** for the interface, **CesiumJS** for the globe and **Vite** to build it — plus **Google Photorealistic 3D Tiles** for the planet and the **OpenAI Realtime API** for voice. Fast to read, fast to hack on.
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── main.js # Bootstrap: Google 3D tiles, layer registration
|
├── main.js # Bootstrap: Google 3D tiles, layer registration
|
||||||
├── ui/ # Runtime UI — panels, HUD, styles, controls
|
├── ui/ # Runtime UI — Vue views, Pinia stores, kit, controllers, styles
|
||||||
├── hud.js # Intelligence HUD + AI scene summary
|
├── hud.js # Intelligence HUD + AI scene summary
|
||||||
├── keySetup.js # POWER UP panel — in-app provider keys (dev server only)
|
├── keySetup.js # POWER UP panel — in-app provider keys (dev server only)
|
||||||
├── mapStackController.js # Basemap switching — Google 3D / Esri / OSM / ion stacks
|
├── mapStackController.js # Basemap switching — Google 3D / Esri / OSM / ion stacks
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
|
||||||
|
|
||||||
export const APPLICATION_TEMPLATES = Object.freeze([
|
|
||||||
'scene-chrome',
|
|
||||||
'cockpit',
|
|
||||||
'display-controls',
|
|
||||||
'command-dock',
|
|
||||||
'layer-panels',
|
|
||||||
'context',
|
|
||||||
'welcome',
|
|
||||||
'provider-settings',
|
|
||||||
'hud-loading',
|
|
||||||
]);
|
|
||||||
const allowed = new Set(APPLICATION_TEMPLATES);
|
|
||||||
|
|
||||||
/** Expand only known component templates; markers cannot name filesystem paths. */
|
|
||||||
export function expandApplicationHtml(html) {
|
|
||||||
return html.replace(
|
|
||||||
/^[ \t]*<!-- gev:template ([^\s]+) -->\r?\n?/gm,
|
|
||||||
(_, name) => {
|
|
||||||
if (!allowed.has(name))
|
|
||||||
throw new Error(`Unknown application template: ${name}`);
|
|
||||||
return readFileSync(
|
|
||||||
new URL(`../src/ui/templates/${name}.html`, import.meta.url),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assemble static application markup before Vite processes scripts and assets. */
|
|
||||||
export function applicationHtmlPlugin() {
|
|
||||||
return {
|
|
||||||
name: 'application-component-templates',
|
|
||||||
transformIndexHtml: {
|
|
||||||
order: 'pre',
|
|
||||||
handler: expandApplicationHtml,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { registerHooks } from 'node:module';
|
||||||
|
import { load } from './vueLoader.js';
|
||||||
|
|
||||||
|
// Lets Node import single-file components: `node --import=<this file>`.
|
||||||
|
// Hosts reach it as `<core package>/build/register-vue`. The hooks run on the
|
||||||
|
// loading thread: off-thread hooks (module.register) would add a round trip
|
||||||
|
// to every module a test imports, not only the components.
|
||||||
|
registerHooks({ load });
|
||||||
+10
-2
@@ -1,6 +1,7 @@
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { applicationHtmlPlugin } from './application-html.js';
|
|
||||||
import cesium from 'vite-plugin-cesium';
|
import cesium from 'vite-plugin-cesium';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { VUE_PLUGIN_OPTIONS } from './vueOptions.js';
|
||||||
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
|
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
|
||||||
import { embedFramingPlugin } from './embed-framing.js';
|
import { embedFramingPlugin } from './embed-framing.js';
|
||||||
import { panelBuildPlugin } from './panel.js';
|
import { panelBuildPlugin } from './panel.js';
|
||||||
@@ -155,7 +156,8 @@ export function createBrowserViteConfig({
|
|||||||
// Next, so no provider route answers a Host the server does not allow.
|
// Next, so no provider route answers a Host the server does not allow.
|
||||||
hostCheckPlugin(),
|
hostCheckPlugin(),
|
||||||
cesium(),
|
cesium(),
|
||||||
applicationHtmlPlugin(),
|
// Application views are Vue single-file components.
|
||||||
|
vue(VUE_PLUGIN_OPTIONS),
|
||||||
...plugins,
|
...plugins,
|
||||||
embedFramingPlugin(),
|
embedFramingPlugin(),
|
||||||
panelBuildPlugin(),
|
panelBuildPlugin(),
|
||||||
@@ -209,6 +211,12 @@ export function createBrowserViteConfig({
|
|||||||
// framing + CSP hardening (one constant, no drift).
|
// framing + CSP hardening (one constant, no drift).
|
||||||
preview: { headers: BROWSER_HEADERS },
|
preview: { headers: BROWSER_HEADERS },
|
||||||
define: {
|
define: {
|
||||||
|
// vue-i18n holds the locale and catalogs; Core formats the messages
|
||||||
|
// (src/ui/i18n.js), so the build drops vue-i18n's message compiler.
|
||||||
|
__VUE_I18N_FULL_INSTALL__: 'false',
|
||||||
|
__VUE_I18N_LEGACY_API__: 'false',
|
||||||
|
__INTLIFY_PROD_DEVTOOLS__: 'false',
|
||||||
|
__INTLIFY_DROP_MESSAGE_COMPILER__: 'true',
|
||||||
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
|
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
|
||||||
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken),
|
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken),
|
||||||
// Mapillary client tokens are public by design (see SECURITY.md).
|
// Mapillary client tokens are public by design (see SECURITY.md).
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { VUE_TEMPLATE_OPTIONS } from './vueOptions.js';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
// The compiler loads on the first component that is not cached, so a test
|
||||||
|
// process that imports no views, or only cached ones, never pays for it.
|
||||||
|
let compiler = null;
|
||||||
|
const sfcCompiler = () => (compiler ??= require('vue/compiler-sfc'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile a single-file component to an ES module for Node, so unit tests (the
|
||||||
|
* application's and a host's) can import components without a bundler.
|
||||||
|
* Styles are dropped: tests read the global stylesheet separately.
|
||||||
|
*/
|
||||||
|
export function compileVueModule(source, filename) {
|
||||||
|
const { compileScript, compileTemplate, parse } = sfcCompiler();
|
||||||
|
// The template is parsed with the SFC; parse it as the browser build does.
|
||||||
|
const { descriptor, errors } = parse(source, {
|
||||||
|
filename,
|
||||||
|
templateParseOptions: VUE_TEMPLATE_OPTIONS.compilerOptions,
|
||||||
|
});
|
||||||
|
if (errors.length) throw errors[0];
|
||||||
|
const id = filename;
|
||||||
|
if (descriptor.scriptSetup) {
|
||||||
|
return compileScript(descriptor, {
|
||||||
|
id,
|
||||||
|
inlineTemplate: true,
|
||||||
|
templateOptions: VUE_TEMPLATE_OPTIONS,
|
||||||
|
}).content;
|
||||||
|
}
|
||||||
|
const script = descriptor.script
|
||||||
|
? compileScript(descriptor, { id, genDefaultAs: '__sfc__' }).content
|
||||||
|
: 'const __sfc__ = {};';
|
||||||
|
let template = '';
|
||||||
|
if (descriptor.template) {
|
||||||
|
const compiled = compileTemplate({
|
||||||
|
source: descriptor.template.content,
|
||||||
|
filename,
|
||||||
|
id,
|
||||||
|
...VUE_TEMPLATE_OPTIONS,
|
||||||
|
});
|
||||||
|
if (compiled.errors.length) throw compiled.errors[0];
|
||||||
|
template = compiled.code;
|
||||||
|
}
|
||||||
|
return `${script}\n${template}\nif (typeof render === 'function') __sfc__.render = render;\nexport default __sfc__;\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The loader's own code is part of every key, so a change to how components
|
||||||
|
// compile never reuses output from before it.
|
||||||
|
let loaderFingerprint = null;
|
||||||
|
const fingerprint = () =>
|
||||||
|
(loaderFingerprint ??= createHash('sha256')
|
||||||
|
.update(readFileSync(fileURLToPath(import.meta.url)))
|
||||||
|
.update(readFileSync(new URL('./vueOptions.js', import.meta.url)))
|
||||||
|
.update(require('vue/package.json').version)
|
||||||
|
.digest('hex'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where compiled components are kept between test processes, never a
|
||||||
|
* package's own tree (a host's checks refuse files inside Core): the system
|
||||||
|
* temp directory, or on Windows the per-user local app data directory, since
|
||||||
|
* a temp directory there may be shared and its ownership is not checked.
|
||||||
|
* GEV_VUE_CACHE=0 compiles every time.
|
||||||
|
*/
|
||||||
|
export function vueCacheDirectory(
|
||||||
|
env = process.env,
|
||||||
|
platform = process.platform,
|
||||||
|
) {
|
||||||
|
if (env.GEV_VUE_CACHE === '0') return null;
|
||||||
|
if (platform === 'win32') {
|
||||||
|
return env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'gev-vue-sfc') : null;
|
||||||
|
}
|
||||||
|
return path.join(tmpdir(), `gev-vue-sfc-${process.getuid()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cache entry for one component: its path, its text and the loader. */
|
||||||
|
export function vueCacheKey(source, filename) {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(JSON.stringify([fingerprint(), filename]))
|
||||||
|
.update('\0')
|
||||||
|
.update(source)
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile a component, reusing a previous process's result for the same text.
|
||||||
|
* A cache that cannot be read or written only costs the compile.
|
||||||
|
*/
|
||||||
|
export function cachedVueModule(
|
||||||
|
source,
|
||||||
|
filename,
|
||||||
|
directory = vueCacheDirectory(),
|
||||||
|
) {
|
||||||
|
if (!directory || !ownCacheDirectory(directory)) {
|
||||||
|
return compileVueModule(source, filename);
|
||||||
|
}
|
||||||
|
const entry = path.join(directory, `${vueCacheKey(source, filename)}.mjs`);
|
||||||
|
try {
|
||||||
|
return readFileSync(entry, 'utf8');
|
||||||
|
} catch {
|
||||||
|
// Not compiled yet.
|
||||||
|
}
|
||||||
|
const compiled = compileVueModule(source, filename);
|
||||||
|
// Concurrent test processes and threads may compile the same component;
|
||||||
|
// each writes its own new file and the rename makes the entry appear whole.
|
||||||
|
const partial = `${entry}.${randomBytes(6).toString('hex')}.tmp`;
|
||||||
|
try {
|
||||||
|
writeFileSync(partial, compiled, { flag: 'wx', mode: 0o600 });
|
||||||
|
renameSync(partial, entry);
|
||||||
|
} catch {
|
||||||
|
// An unwritable directory, or a rename another process holds open,
|
||||||
|
// leaves the cache without this entry.
|
||||||
|
try {
|
||||||
|
rmSync(partial, { force: true });
|
||||||
|
} catch {
|
||||||
|
// A partial file that cannot be removed is only clutter.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return compiled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached modules run as code, so the directory must be this user's own: a
|
||||||
|
* real directory (not a link), owned by the current user and writable by no
|
||||||
|
* one else. In a shared temp directory another user could otherwise create it
|
||||||
|
* first and plant modules. On Windows it sits in the user's own profile.
|
||||||
|
*/
|
||||||
|
const checkedDirectories = new Map();
|
||||||
|
export function ownCacheDirectory(directory) {
|
||||||
|
if (!checkedDirectories.has(directory)) {
|
||||||
|
checkedDirectories.set(directory, isOwnDirectory(directory));
|
||||||
|
}
|
||||||
|
return checkedDirectories.get(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOwnDirectory(directory) {
|
||||||
|
try {
|
||||||
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||||
|
const stats = lstatSync(directory);
|
||||||
|
if (!stats.isDirectory()) return false;
|
||||||
|
if (process.platform === 'win32') return true;
|
||||||
|
return stats.uid === process.getuid() && (stats.mode & 0o022) === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synchronous module hook (module.registerHooks): `.vue` loads compiled. */
|
||||||
|
export function load(url, context, nextLoad) {
|
||||||
|
if (!url.endsWith('.vue')) return nextLoad(url, context);
|
||||||
|
const filename = fileURLToPath(url);
|
||||||
|
return {
|
||||||
|
format: 'module',
|
||||||
|
source: cachedVueModule(readFileSync(filename, 'utf8'), filename),
|
||||||
|
shortCircuit: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Template options shared by the browser build, the boundary check and the
|
||||||
|
* unit-test loader. Whitespace between nodes is kept (as a single space) so
|
||||||
|
* an application view renders as its static markup did; Prettier formats
|
||||||
|
* single-file components with strict whitespace sensitivity to match. Asset
|
||||||
|
* URLs such as `/pin.svg` stay literal instead of becoming module imports.
|
||||||
|
*/
|
||||||
|
export const VUE_TEMPLATE_OPTIONS = Object.freeze({
|
||||||
|
compilerOptions: Object.freeze({ whitespace: 'preserve' }),
|
||||||
|
transformAssetUrls: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** `@vitejs/plugin-vue` options for application builds. */
|
||||||
|
export const VUE_PLUGIN_OPTIONS = Object.freeze({
|
||||||
|
template: VUE_TEMPLATE_OPTIONS,
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Director clock listeners, interaction observers and scene release failures
|
||||||
|
no longer leave the director inconsistent: each scene is released once, and
|
||||||
|
a phase error is kept (with the release failure logged) when cleanup also
|
||||||
|
fails (deepeshbind, #1014).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- FIRMS fire data loads on hosts where IPv6 is unreachable and IPv4 is slow to
|
||||||
|
connect: a request whose every address fails to connect retries once over
|
||||||
|
IPv4, and FIRMS warnings name the underlying connection errors instead of
|
||||||
|
only `fetch failed` (jeffblakely, #1078).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Keep Directions route errors, the Local ADS-B receiver error and Global
|
||||||
|
Context cohort reasons English in layer data; the Layers panel, Radio card
|
||||||
|
and awareness panel still show them in the interface language. The route
|
||||||
|
proxy's "routing busy" and "route proxy error" refusals are now translated
|
||||||
|
too (daikaginza, #1209).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Pause the HUD's camera readout while the page is hidden, as polled layers
|
||||||
|
already do. An app embedded inline in a panel keeps both updating, because the
|
||||||
|
panel's host can report the page as hidden while it is on screen (nu-stats,
|
||||||
|
#1344).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Restore a shot's declared layer visibility when seeking within it after manual
|
||||||
|
layer changes, including toggles still in flight. Only changed layers are
|
||||||
|
reconciled; refused restoration reports a failed seek and keeps the last
|
||||||
|
position, while undeclared operator layers are preserved (@oneill9, #1374).
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- A "place, region" search such as "Paris, Texas" now lands on the place in
|
||||||
|
that region instead of a namesake elsewhere (quanticsoul4772, #1401).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- The terrain proxy stops asking the upstream for a while after a 429
|
||||||
|
(its Retry-After, at most 10 minutes, or 60 s) instead of retrying on every
|
||||||
|
page request, and a Retry-After longer than its 10 s retry budget now ends
|
||||||
|
the retries at once instead of sleeping the budget out (quanticsoul4772,
|
||||||
|
#1402).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Fire Perimeters and `get_fire_perimeters` say when WFIGS is unreachable and
|
||||||
|
the last snapshot is being shown (STALE, its age, "data may be stale"), and
|
||||||
|
that snapshot is served for at most 24 hours (quanticsoul4772, #1404).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- The Traffic layer reads STALE instead of LIVE when the TomTom proxy serves
|
||||||
|
cached flow tiles (budget spent or TomTom failing), and the row dates them by
|
||||||
|
the oldest cached tile (quanticsoul4772, #1405).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Show a vessel course of 359.5° or more as 0°. Vessel cards and the AIS HUD
|
||||||
|
rounded the course without wrapping, so a ship with no reported heading on
|
||||||
|
a 359.7° course read "360°" and "HDG: 360DEG" (Aditya, @Rohilalala, #1432).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- Show a Contacts marker 999.5 m or more away as 1.0 km. The on-globe marker
|
||||||
|
chose metres before rounding, so 999.6 m read "1000 m" while the panel read
|
||||||
|
"1.0 km" (Aditya, @Rohilalala, #1433).
|
||||||
|
- Distance labels share one formatter that takes each unit cut after rounding,
|
||||||
|
so the Contacts panel and markers, the drawn-line measure and the Cockpit
|
||||||
|
show 9.96 km as 10 km rather than 10.0 km (#1433).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Selecting the nearest aircraft by voice no longer says "0 feet" for a contact
|
||||||
|
whose feed reported no altitude; the reply and card leave the altitude out
|
||||||
|
(Aditya, @Rohilalala, #1434).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Military aircraft whose transponders report a GNSS vertical rate now carry it
|
||||||
|
in analyst and tracked-aircraft data instead of null (Aditya, @Rohilalala,
|
||||||
|
#1436).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Show a CCTV camera facing 359.5° or more as HDG 0°. The HUD heading token
|
||||||
|
and the camera panel rounded the bearing without wrapping, so a camera
|
||||||
|
facing 359.6° read "HDG 360°" (Aditya, @Rohilalala, #1441).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Keep Street Traffic's flow status honest at the edges. A few TomTom-matched
|
||||||
|
dots among hundreds rounded to 0% and read "(no matches)", and a few
|
||||||
|
simulated dots rounded to "100% cov"; coverage now stays within 1–99% while
|
||||||
|
both are shown (Aditya, @Rohilalala, #1442).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Run Space Missions' Replay Ascent countdown from T−10 after the five-second
|
||||||
|
tile hold; the hold ran 10 s and the countdown started at T−05 (Aditya,
|
||||||
|
@Rohilalala, #1443).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Keep Space Missions' replay countdown in real time when the replay speed
|
||||||
|
changes before liftoff; 0.25× had stretched T−8 to T−32 (Aditya,
|
||||||
|
@Rohilalala, #1444).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Label a route, arrow or drawn line of 995 m or more as 1.0 km, and of
|
||||||
|
9,950 m or more as 10 km. The placed label picked the unit before rounding,
|
||||||
|
so it read "1000 m" and "10.0 km" (Aditya, @Rohilalala, #1445).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Show a drawn area of 9,999.5 m² or more as 1.0 ha, and of 999,500 m² or
|
||||||
|
more as 1.00 km². The draw hint picked the unit before rounding, so it read
|
||||||
|
"10000 m²" and "100.0 ha" (Aditya, @Rohilalala, #1446).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- List each submarine cable once in find_submarine_cables. TeleGeography
|
||||||
|
splits 17 cables (2Africa, Echo, Hawaiki) into several features, so a
|
||||||
|
search for Echo read "3 submarine cables" (Aditya, @Rohilalala, #1449).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Show a route of 9.95 km or more as 10 km. The Directions row and step
|
||||||
|
cards kept one decimal until the raw distance reached 10 km, so 9.96 km
|
||||||
|
read "10.0 km" (Aditya, @Rohilalala, #1450).
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- Show an ALPR camera's mapped bearing of 359.5° or more as DIRECTION 0°
|
||||||
|
rather than DIRECTION 360° (Aditya, @Rohilalala, #1451).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Show a transit vehicle's reported bearing of 359.5° or more as 0°. The
|
||||||
|
selected-vehicle card rounded it without wrapping, so a bus heading 359.6°
|
||||||
|
read "reported hdg 360°" (Aditya, @Rohilalala, #1452).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- Space Missions no longer shows an older satellite with a similar name and a
|
||||||
|
different known launch year as a new payload's orbit: the launch-year check
|
||||||
|
on loaded satellites now runs (Aditya, @Rohilalala, #1453).
|
||||||
|
- Satellite launch years come from one TLE designator parser for both the
|
||||||
|
loaded catalog and the active-TLE fallback; a blank designator is an
|
||||||
|
unknown year instead of 2000 (#1453).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Describe a selected flight or military contact on 359.5° or more to the
|
||||||
|
voice assistant as heading 0°, not 360° (Aditya, @Rohilalala, #1454).
|
||||||
|
- Heading and bearing readouts share one rounding helper, so the Cockpit's
|
||||||
|
TRACK ACQUIRED course no longer reads 360°, and a negative bearing wraps
|
||||||
|
instead of printing a minus sign (#1454).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Cut the Google requests the HUD's AI summary makes on its own. It no longer
|
||||||
|
looks up place names when no OpenAI key is configured, reverse-geocodes at
|
||||||
|
most two points instead of up to four, and looks up again only once the view
|
||||||
|
shows a different area, not after every small pan, so a small pan no longer
|
||||||
|
asks OpenAI for a new summary either (samehkhamis, #1459).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Update the development tools to Vite 6.4.4 and Puppeteer 25.16.0, which
|
||||||
|
clears the open npm advisories for the dev server and the browser test
|
||||||
|
tooling, and remove the unused local screenshot helper `scripts/shot-sink.mjs`
|
||||||
|
(samehkhamis, #1463).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Give the side panels one look. Every panel title now uses the command dock's
|
||||||
|
label type in a brighter colour; a panel's edge and title light up on hover,
|
||||||
|
as the dock's trays do; and in the default theme each collapse button sits
|
||||||
|
19px from its panel's edge (plus the scrollbar's width in an open, scrolling
|
||||||
|
panel where the system always shows scrollbars) (asturksever, #1466).
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Rename the Scenes panel to Authoring and lay it out in steps (Scene, Shots,
|
||||||
|
Preview, Save & share) with larger, higher-contrast text, numbered shots, a
|
||||||
|
tooltip on every action and buttons that are disabled when they would do
|
||||||
|
nothing (asturksever, #1472).
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Add Arabic (ar), the first right-to-left language: the title, rails, dock and
|
||||||
|
corner row mirror, numbers keep Western digits, and plurals use all six
|
||||||
|
Arabic forms. FIRMS cards and Space Missions landing labels are now
|
||||||
|
translated in every language. Based on translations by @abiyeenzo (#915)
|
||||||
|
(German, @gekh, #1479).
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Let a GNSS integrity source set the layer's evidence window (`windowMs`). A
|
||||||
|
missing or invalid value keeps the 30-minute default, and the layer row now
|
||||||
|
names the window in use instead of a fixed "30 min" (samehkhamis, #1491).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Let a weather source describe its own products through an optional
|
||||||
|
`display` object in each manifest (source, credit, units, coverage, info,
|
||||||
|
legend and extent). Each valid field replaces the built-in value, and
|
||||||
|
manifests without it render as before (samehkhamis, #1494).
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
- The language picker switches language in place instead of reloading the
|
||||||
|
page. Views re-render; code that publishes translated text words it again
|
||||||
|
through `onLocaleChange` (`src/i18n/index.js`), and `<html lang>`/`dir` and
|
||||||
|
the page's `data-i18n` markup follow. `persistLocale` replaces
|
||||||
|
`persistLocaleAndReload`, and `registerUnsavedWork` and the switch's
|
||||||
|
unsaved-work prompt are removed, since a switch no longer loses anything
|
||||||
|
(samehkhamis, #1499).
|
||||||
|
|
||||||
|
- A map source's `unavailableReason` may be a function, read when the reason
|
||||||
|
is shown so it follows the locale; the sources from `createDefaultMapSources`
|
||||||
|
(`./maps/defaults`) give functions. `MapSourceController` reads either form
|
||||||
|
(samehkhamis, #1499).
|
||||||
|
|
||||||
|
- The application views translate their text with `t()` as they render,
|
||||||
|
so it follows the active locale; they no longer carry `data-i18n*`
|
||||||
|
attributes, and `npm run i18n` rejects those in a Vue template. The boot
|
||||||
|
pass (`applyDocumentTranslations`) still translates static page markup
|
||||||
|
such as the loading screen and a host's own markup (samehkhamis, #1499).
|
||||||
|
|
||||||
|
- The page's locale lives in a vue-i18n instance installed in every
|
||||||
|
application view, and `t()`, `tn()` and the Intl formatters read it from
|
||||||
|
there, so a view that translates while rendering renders again when the
|
||||||
|
locale changes. `bindLocale` (`src/i18n/index.js`) keeps the locale in a
|
||||||
|
given ref; Core's Vite config builds vue-i18n without its message
|
||||||
|
compiler. Catalogs, messages and call sites are unchanged
|
||||||
|
(samehkhamis, #1499).
|
||||||
|
|
||||||
|
- Cockpit's Display popover shows the Display panel's own HUD, Detection,
|
||||||
|
Parameters and 3D groups through Vue teleports: `CockpitDisplayPortal`
|
||||||
|
publishes the popover's slots (`publish`, with `afterRender` to lay out once
|
||||||
|
they have moved) instead of moving the groups itself (samehkhamis, #1499).
|
||||||
|
|
||||||
|
- The Cockpit utility strip's measured layout renders from the `gev-cockpit`
|
||||||
|
store: the layout pass publishes `utilityLayout` (the HUD's
|
||||||
|
`--cockpit-utility-*` lengths, `primaryOnly` and `hideCollapsed`), and a host
|
||||||
|
constructing `CockpitController` passes `publish.utilityLayout` beside its
|
||||||
|
other publishers (samehkhamis, #1499).
|
||||||
|
|
||||||
|
- The Cockpit Contact and briefing cards' collapse renders from the
|
||||||
|
`gev-cockpit` store: the controller publishes `contextCollapsed` and
|
||||||
|
`signalCollapsed` in its presence, and each card's `data-collapsed` and its
|
||||||
|
toggle's state, words and icon follow it (samehkhamis, #1499).
|
||||||
|
|
||||||
|
- The panels' own state classes (`collapsed`, `dock-pinned`, `dock-pinned-top`,
|
||||||
|
`layout-auto-collapsed`, `accordion-collapsed`) render from the
|
||||||
|
`gev-panel-chrome` store: the shell, the rails and the accordion present each
|
||||||
|
panel's state there instead of writing its classes. `layoutLeftPanelRail` and
|
||||||
|
`layoutRightPanelRail` (`./ui/layout`) now call
|
||||||
|
`onCollapse(panel, {collapsed, auto})` and leave drawing it to the host.
|
||||||
|
Without a shell to own the panels, the voice tools' `set_panel_open` now
|
||||||
|
reports that there is no panel to open instead of writing its classes
|
||||||
|
(samehkhamis, #1499).
|
||||||
|
|
||||||
|
- The shell's panel chrome no longer writes the panels' collapse buttons, the
|
||||||
|
dock tray toggles' names or the dock pins' pressed state: it presents each
|
||||||
|
panel's open and pinned state to the `gev-panel-chrome` store, and the
|
||||||
|
buttons, now views, call it there. `bindPanelDisclosure` (`./ui/panels`) binds
|
||||||
|
only a panel's Escape; it no longer takes `buttons` or `onChange`. `UiButton`
|
||||||
|
joins the component kit (samehkhamis, #1499).
|
||||||
|
|
||||||
|
- `SceneControls` (`./ui/scenes`) no longer takes `elements` or writes the Scene
|
||||||
|
panel's buttons, status, progress or runtime chip: it presents them to the
|
||||||
|
`gev-scenes` store and the panel's buttons call its actions there.
|
||||||
|
`sceneElements`, `presentSceneButtons`, `presentSceneProgress` and
|
||||||
|
`presentSceneRuntime` give way to `sceneButtonsDisabled` and `scenePercent`.
|
||||||
|
It no longer stands down when the page has no Scene panel: ESC still stops a
|
||||||
|
playing scene and the runtime chip still shows (samehkhamis, #1499).
|
||||||
|
|
||||||
|
- `initFirstRunExperience` (via `./application/chrome`) no longer writes the
|
||||||
|
launcher's classes, attributes or text, or removes the card: it presents them
|
||||||
|
to the welcome view's `gev-first-run` store (its new `store` option) and
|
||||||
|
connects the controls the view's buttons call (samehkhamis, #1499).
|
||||||
|
|
||||||
|
- `IntelHUD` (`./ui/hud`) no longer builds or writes `#intel-hud`: it hands its
|
||||||
|
readouts, visibility, layout variant and style colors to its `publish` option,
|
||||||
|
and Core's `intel-hud` view renders them. A page places that view with
|
||||||
|
`<div class="gev-view" data-gev-view="intel-hud">` instead of a bare
|
||||||
|
`#intel-hud` element. `./layers/vessels` shows the selected vessel's HUD
|
||||||
|
readout through `services.readouts.presentSelectedVessel()` instead of writing
|
||||||
|
`#hud-ais-vessel`; the application passes `src/hudReadouts.js`, which IntelHUD
|
||||||
|
follows (samehkhamis, #1499).
|
||||||
|
|
||||||
|
- `LocationControls` (`./ui/location`) no longer builds the city and landmark
|
||||||
|
pills or writes the mini status: it presents the tray, including the search
|
||||||
|
error (`setSearchError()`), and hands it to its `publish` option, and Core's
|
||||||
|
dock renders it and calls `chooseCity()` and `choosePoi()`. Its `elements` are
|
||||||
|
now only `search`: `resetButtons` and `searchToggle` are gone, and the reset
|
||||||
|
buttons and the search toggle call its new `reset()` and `toggleSearch()`. It
|
||||||
|
presents the search field's open and searching state too (`searchExpanded`,
|
||||||
|
`searching`; a `searchExpanded` option starts it open), set through
|
||||||
|
`setSearching()`, `settleSearch()` and `closeSearch()`, and a host renders the
|
||||||
|
field's `expanded` and `searching` classes from it; `LocationSearch` takes a
|
||||||
|
`setSearching` option in place of writing the field's class, and
|
||||||
|
`NavigationController` a `settleSearch` option in place of `searchInput`.
|
||||||
|
`createOrbitIndicator()` is replaced by `showOrbit(active)`, which adds the
|
||||||
|
ORBIT indicator to the presented tray as `orbit: { active }` (null until
|
||||||
|
offered) (samehkhamis, #1499).
|
||||||
|
|
||||||
|
- `createMapSourceControls()` (`./ui/maps`) no longer renders the map source
|
||||||
|
chips into a `container` or writes a `statusElement`: it presents the chip row
|
||||||
|
and status badge and hands them to its `publish` option, and Core's dock
|
||||||
|
renders them and calls `select()`. `renderMapStackChips`, `syncMapStackChips`
|
||||||
|
and `MAP_STACK_CHIP_CLASS` are removed (samehkhamis, #1499).
|
||||||
|
|
||||||
|
- `./layers/launches` no longer writes the Space Missions roster or the selected
|
||||||
|
mission panel into `#space-mission-panel-host`: the layer publishes both
|
||||||
|
through `subscribePanel(listener)` and Core's Space Missions views render
|
||||||
|
them, calling the layer's panel commands (`selectMissionAt`,
|
||||||
|
`selectPreviousMission`, `selectNextMission`, `deselectMission`,
|
||||||
|
`focusSelectedLaunchSite`, `startSelectedReplay`, `toggleReplayPause`,
|
||||||
|
`cancelReplay`, `setReplaySpeed` and the `roster*` preview inputs). The
|
||||||
|
`bindMissionRosterItemKeyboardPreview`, `captureMissionRosterFocus` and
|
||||||
|
`restoreMissionRosterFocus` helpers are gone; the roster keeps focus itself
|
||||||
|
(samehkhamis, #1499).
|
||||||
|
|
||||||
|
- `./layers/awareness` no longer writes the Contacts panel into
|
||||||
|
`#military-awareness-panel`: the layer publishes a plain panel view through
|
||||||
|
`subscribePanel(listener)` and Core's Contacts view renders it, calling the
|
||||||
|
layer's `navigatePrevious`, `focusCurrent`, `navigateNext` and `focusTarget`.
|
||||||
|
The `awarenessPanelControlKey`, `captureAwarenessPanelFocus` and
|
||||||
|
`restoreAwarenessPanelFocus` helpers are gone; the view keeps focus itself
|
||||||
|
(samehkhamis, #1499).
|
||||||
|
|
||||||
|
- `./ui/cctv` and `./ui/radio` import Vue and Pinia: their controllers publish
|
||||||
|
the panel's state to a store that the CCTV and Radio views render, instead of
|
||||||
|
writing the panel's markup themselves (samehkhamis, #1499).
|
||||||
|
|
||||||
|
- `./ui/display`'s `bindDisplayControls` is gone: the Display panel's controls
|
||||||
|
and the dock's style buttons call their owner through the display store's
|
||||||
|
`connect`/`act`, and its allocation and 3D model choices are the kit's
|
||||||
|
`UiSegmented`. `initDrawTool` takes `connect(actions)`, which returns their
|
||||||
|
disconnect, and no longer listens on the Draw controls; its view publishes
|
||||||
|
`color` and `label` too, and the shape choice is a `UiSegmented`.
|
||||||
|
`initGeoFileImport` takes `connect(actions)` the same way and no longer
|
||||||
|
listens on the Import button, picker or list, nor restores focus itself: the
|
||||||
|
`GeoImport` view does both, and its `publish` need not settle once the list
|
||||||
|
renders. Street Level's fit and panorama filters and Recent Imagery's mode are
|
||||||
|
`UiSegmented` too: the Street Level store's owner answers `setPano` and
|
||||||
|
`setRenderMode`, and `RecentImageryReadout` takes `onSetMode`. The Radio
|
||||||
|
panel's and Cockpit's internet radio controls and the tuner slider's events
|
||||||
|
call `RadioControls`, and the local RTL-SDR card's controls
|
||||||
|
`LocalSdrControls`, through the radio store's new `connect`/`act`; the card
|
||||||
|
labels its gain options itself. `act` returns what the answering action
|
||||||
|
returned. The Display, Radio, RTL-SDR, Cockpit, mission replay, style
|
||||||
|
parameter, rail card, observation timeline, imagery opacity, Street Level date
|
||||||
|
and Bhote Koshi sliders are the kit's `UiSlider`, a native range input that
|
||||||
|
emits numbers, and the pressed toggles are the kit's `UiButton`, which gains
|
||||||
|
`pressedClass`; so are the rail card chips, map source chips and location
|
||||||
|
pills (which now report `aria-pressed`), and the voice card's chips are the
|
||||||
|
kit's new `UiChip`. The selects are the kit's new `UiSelect`, the status
|
||||||
|
badges its new `UiBadge` and the one-line empty states its new `UiEmpty`. The
|
||||||
|
top-center clear, share, tilt, north-up and reset buttons, and the cockpit's
|
||||||
|
reset, call the shell through the new globe actions store, which also carries
|
||||||
|
the camera's tilt and compass heading. The recording safe frame renders what
|
||||||
|
`RecordingControls` publishes to the new recording store. `syncScopeControls`
|
||||||
|
(`./ui/display`), `shouldHideCollapsedRightPanels` (`./ui/layout`),
|
||||||
|
`bindClearLayersControl` (`./ui/layers`) and `createStyleParameters`
|
||||||
|
(`./ui/input`; the Display panel renders the style parameters VisualSettings
|
||||||
|
publishes to the display store) are deprecated: the Display view, the rail and
|
||||||
|
the globe actions view now own what they did. `./ui/input` imports Vue
|
||||||
|
(samehkhamis, #1499).
|
||||||
|
|
||||||
|
- `./scenes` presents the scene sharing dialogs and the EDIT DETAILS / SHARE
|
||||||
|
SCENE bar through a store that the scene panels view renders, so a page that
|
||||||
|
omits `layer-panels` shows neither (samehkhamis, #1499).
|
||||||
|
|
||||||
|
- `./voice/control`'s `createVoiceControl({ panel })` returns the voice
|
||||||
|
control's presenter instead of element handles: owners write it by part
|
||||||
|
(`update('detail', { text })`) and listen with `on('button', fn)`, and the
|
||||||
|
command dock renders it from a store. `createVoiceCommands`' `createControl`
|
||||||
|
and `VoiceCardControls` (`{ ui }` instead of `{ root }`) take the same
|
||||||
|
presenter; the `reset` option is gone, since a newer control replaces the
|
||||||
|
previous one (samehkhamis, #1499).
|
||||||
|
|
||||||
|
- Pages place each view with a `<div class="gev-view" data-gev-view="NAME">`
|
||||||
|
host and write their own loading screen; the `build/html` template expansion
|
||||||
|
and its `gev:template` markers are gone. `createApplication` takes `omitViews`
|
||||||
|
(for example `['provider-settings']`) for views an application leaves out
|
||||||
|
(samehkhamis, #1499).
|
||||||
|
|
||||||
|
- The page's panels, dock, cockpit and dialogs are Vue views with the same
|
||||||
|
markup as before. Hosts that do not initialize Core's locale pass
|
||||||
|
`localePicker: false` to `createApplicationControls` to drop the language
|
||||||
|
picker, and Node tests that import application modules register the component
|
||||||
|
loader with `--import=<core>/build/register-vue` (samehkhamis, #1499).
|
||||||
|
|
||||||
|
- The Data, Authoring, Weather, Recent Imagery, Context, CCTV, Street Level and
|
||||||
|
Radio panels are one component, `UiPanel` (`src/ui/kit/`), with the same
|
||||||
|
markup plus the kit's `ui-panel` classes, their titles translated by `t()`
|
||||||
|
rather than `data-i18n`; `RailPanel` binds it to the panel chrome store.
|
||||||
|
`PanelCollapseButton` drops its `initial` and `initialTitle` props and takes
|
||||||
|
`startCollapsed`, so before startup presents a panel its button already shows
|
||||||
|
that state's glyph, words and `aria-expanded` (samehkhamis, #1499).
|
||||||
|
|
||||||
|
- The Authoring, shot list and CCTV buttons and Street Level's nearest button
|
||||||
|
are the kit's `UiButton` (danger buttons in the `alert` tone, primary ones the
|
||||||
|
`primary` variant), keeping their classes; the CCTV on/off, coverage,
|
||||||
|
auto-hop, projection and adjust toggles now say their state with
|
||||||
|
`aria-pressed` (samehkhamis, #1499).
|
||||||
|
|
||||||
|
- Each desktop sidebar has an icon rail that lists every panel on that side and
|
||||||
|
opens one with a click. One panel is open per side in every HUD layout, so the
|
||||||
|
collapsed panel headers no longer take up the sidebars. Phones keep the bottom
|
||||||
|
tab bar. The rail is the first view built with Vue and Pinia, which now ship
|
||||||
|
with the app; see docs/VUE-MIGRATION.md (samehkhamis, #1499).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- `build/register-vue` loads components on the test thread and keeps compiled
|
||||||
|
components between test processes in a per-user cache directory, so Node
|
||||||
|
tests that import views run about twice as fast. `GEV_VUE_CACHE=0` compiles
|
||||||
|
every time (samehkhamis, #1500).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- The side panel rails show each panel as a labelled pill while no panel on
|
||||||
|
that side is open, and fold to icons once one opens. `UiRail` takes
|
||||||
|
`collapsible` for this (samehkhamis, #1501).
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
- Let the scene idle with AIS vessels enabled. Vessels no longer hold the
|
||||||
|
continuous render loop; they request frames for new data and selection, one
|
||||||
|
trailing frame after the camera stops, and hold the loop only while focus
|
||||||
|
emphasis animates or a vessel stays dimmed under a tracked target. A parked
|
||||||
|
vessels-only scene went from continuous rendering to 0 frames in 5 s. While
|
||||||
|
anything is tracked, the vessel focus pass skips hidden sprites at rest:
|
||||||
|
about 4 ms to 1.2 ms per pass over 12,000 vessels (waar19, #871).
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
- Add the Autostrada del Brennero (A22, Brenner–Modena) motorway webcams as a
|
||||||
|
CCTV pack, the first Italian cameras. The 13-camera list is one keyless
|
||||||
|
request to the Open Data Hub (NOI Techpark) tourism API, which republishes
|
||||||
|
Autobrennero's own list with coordinates; frames are the operator's stills,
|
||||||
|
refreshed about once a minute and pinned to each camera's own `km<N>.jpg`.
|
||||||
|
Each camera's ground elevation comes from a per-kilometre DEM table, since
|
||||||
|
the road climbs from the Po valley to the Brenner Pass. `CCTV_A22_ENABLED=0`
|
||||||
|
disables the pack (Jibran Tahir, @jibraaan, #956). Closes #155.
|
||||||
+21
-3
@@ -200,6 +200,24 @@ its pending results. Source disposal does not silently reinstate a default.
|
|||||||
`ui/composition` supplies the default engines to the shell. Applications can
|
`ui/composition` supplies the default engines to the shell. Applications can
|
||||||
supply a HUD implementation or request policy through control services. The
|
supply a HUD implementation or request policy through control services. The
|
||||||
shared chrome owns the welcome/loading transition; standalone composition adds
|
shared chrome owns the welcome/loading transition; standalone composition adds
|
||||||
Provider Settings. `build/html` expands an allowlist of component markers from
|
Provider Settings. A page places each Vue view in `src/ui/views` with a host,
|
||||||
`src/ui/templates`; unknown names cannot read arbitrary filesystem paths. The
|
`<div class="gev-view" data-gev-view="NAME"></div>`, and writes its own loading
|
||||||
standalone document expands to the same markup as before this extraction.
|
screen (`#loading-screen` with a `.loader-status` line) as static markup so it
|
||||||
|
paints before any script runs. `createApplication()` renders the views before
|
||||||
|
anything reads the page; `omitViews` names views the application leaves out,
|
||||||
|
such as `provider-settings`, and their hosts are removed. Leaving out
|
||||||
|
`layer-panels` also leaves out the scene sharing dialogs it draws, and leaving
|
||||||
|
out `scene-chrome` the before/after imagery dividers (the imagery still splits,
|
||||||
|
with no line or handle to move it), the status chips, the toast, the ORBIT
|
||||||
|
indicator, the FPS readout, the Scene actions panel, the data-pack attribution
|
||||||
|
cards, the wind inspection marker, the Space Missions replay vehicle, the
|
||||||
|
full-globe celestial ring, the screen-space map annotations, the visible
|
||||||
|
map target list and the Global Context compass;
|
||||||
|
leaving out `command-dock` the voice control and its pointer reticle;
|
||||||
|
leaving out `context` the Bhote Koshi reconstruction panel; and leaving out
|
||||||
|
`intel-hud` the Intel HUD overlay (`#intel-hud`), whose readouts the HUD
|
||||||
|
implementation publishes to the `gev-hud` store. An unknown
|
||||||
|
view name is an error. A host that does not initialize Core's locale passes
|
||||||
|
`localePicker: false` to `createApplicationControls` instead of editing the
|
||||||
|
markup. Node tests that import application modules register the component
|
||||||
|
loader with `node --import=<core package>/build/register-vue`.
|
||||||
+22
-26
@@ -276,7 +276,7 @@ compatible through re-exports.
|
|||||||
|
|
||||||
### Adding a right-rail readout panel
|
### Adding a right-rail readout panel
|
||||||
|
|
||||||
Add the shell in `src/ui/templates/context.html` inside `#right-context-rail`:
|
Add the shell in `src/ui/views/ContextView.vue` inside `#right-context-rail`:
|
||||||
a `.panel-collapsible` with a stable `id` and matching `data-panel-id`, the
|
a `.panel-collapsible` with a stable `id` and matching `data-panel-id`, the
|
||||||
existing `.panel-header`, collapse button and a dedicated body. Register it
|
existing `.panel-header`, collapse button and a dedicated body. Register it
|
||||||
with the existing panel chrome/layout owners; do not allocate rail space in
|
with the existing panel chrome/layout owners; do not allocate rail space in
|
||||||
@@ -297,13 +297,13 @@ compactStatus, blocks }` in place. Their full-width native button headers expose
|
|||||||
adapter owns accordion state. Generic cards do not select data or enable layers.
|
adapter owns accordion state. Generic cards do not select data or enable layers.
|
||||||
|
|
||||||
`blocks` is an ordered array of loose, optional `{ id, type, ... }` descriptors,
|
`blocks` is an ordered array of loose, optional `{ id, type, ... }` descriptors,
|
||||||
rendered by `railCardBlocks.js`. There are no per-section disclosures:
|
rendered by `rail/RailCardBlocks.vue`. There are no per-section disclosures:
|
||||||
|
|
||||||
- `summary`: `text`, a single summary line.
|
- `summary`: `text`, a single summary line.
|
||||||
- `list`: `list: { ariaLabel, items }`, reconciled by `syncRowList` in `rowList.js`.
|
- `list`: `list: { ariaLabel, items }`, keyed by `rail/RailRowList.vue`.
|
||||||
- `lines`: keyed `lines: [{ id, text, muted? }]` for details and status.
|
- `lines`: keyed `lines: [{ id, text, muted? }]` for details and status.
|
||||||
- `settings`: `settings: [{ id, label, chips }]`; each labelled row has a 56 px
|
- `settings`: `settings: [{ id, label, chips }]`; each labelled row has a 56 px
|
||||||
label column and a wrapping `syncChipGroup` from `chipGroup.js`.
|
label column and wrapping keyed chips from `rail/RailChips.vue`.
|
||||||
- `legend`: `legend: { colors, labels, units?, zeroIndex?, categorical? }` for a
|
- `legend`: `legend: { colors, labels, units?, zeroIndex?, categorical? }` for a
|
||||||
ramp/scale (including a physical freezing anchor) or separate keyed swatches.
|
ramp/scale (including a physical freezing anchor) or separate keyed swatches.
|
||||||
- `actions`: keyed `actions: [{ id, label, params?, href?, onClick?, disabled?,
|
- `actions`: keyed `actions: [{ id, label, params?, href?, onClick?, disabled?,
|
||||||
@@ -372,12 +372,10 @@ facade's asynchronous teardown can yield.
|
|||||||
|
|
||||||
## Ordinary action buttons
|
## Ordinary action buttons
|
||||||
|
|
||||||
`src/ui/actionButton.js` is a native, listener-free view helper used by Scene
|
The scene panels view and the CCTV panel draw their action buttons with the shared
|
||||||
presentation and sharing-toolbar owners. It receives translated text and explicit
|
`gev-action-button` classes in `src/ui/styles/action-button.css`. Feature
|
||||||
presentation/accessibility properties. Feature controllers own action dispatch,
|
controllers own action dispatch, state, persistence and cleanup, and the views
|
||||||
state, persistence and cleanup. Static Scenes/CCTV templates use the same CSS
|
preserve IDs and layout hooks.
|
||||||
surface and preserve IDs and layout hooks. The helper imports no application,
|
|
||||||
renderer, storage or services; it belongs to the consuming package closures.
|
|
||||||
|
|
||||||
## Display controls
|
## Display controls
|
||||||
|
|
||||||
@@ -401,9 +399,9 @@ remain in their callers.
|
|||||||
`ui/maps` owns source-chip presentation, selection feedback and its subscription
|
`ui/maps` owns source-chip presentation, selection feedback and its subscription
|
||||||
lifetime. It receives the existing controller and explicit state/action callbacks;
|
lifetime. It receives the existing controller and explicit state/action callbacks;
|
||||||
it imports no renderer or application. Source construction and availability policy
|
it imports no renderer or application. Source construction and availability policy
|
||||||
remain with the map controller. Rebuilding controls removes their previous chip
|
remain with the map controller. The dock renders the chips; rebuilding controls
|
||||||
listeners, and destruction suppresses late completions without owning or destroying
|
drops their previous subscription, and destruction suppresses late completions
|
||||||
the supplied controller.
|
without owning or destroying the supplied controller.
|
||||||
|
|
||||||
## Layer panel
|
## Layer panel
|
||||||
|
|
||||||
@@ -741,13 +739,13 @@ DOM and platform middleware.
|
|||||||
|
|
||||||
### Native form fields
|
### Native form fields
|
||||||
|
|
||||||
`src/ui/formField.js` constructs native numeric inputs without listeners or
|
Native numeric inputs are rendered by the views that own them; the CCTV pose
|
||||||
application state. Static selects and number inputs use the same `gev-field`
|
chip renders its inline input in `src/ui/cctv/CctvCalValue.vue`. Static
|
||||||
surface, with explicit compact and inline densities. Feature owners retain
|
selects and number inputs use the same `gev-field` surface, with explicit
|
||||||
|
compact and inline densities. Feature owners retain
|
||||||
option identity/order, bounds, parsing, draft values, input/change timing and
|
option identity/order, bounds, parsing, draft values, input/change timing and
|
||||||
teardown. CCTV effective-pose conversion and tuner/device operations remain
|
teardown. CCTV effective-pose conversion and tuner/device operations remain
|
||||||
feature responsibilities. A Vue replacement can reuse this contract and CSS;
|
feature responsibilities.
|
||||||
it replaces DOM construction, rather than adding a second state owner.
|
|
||||||
|
|
||||||
The shared stylesheet owns field surfaces and disabled opacity; feature and theme
|
The shared stylesheet owns field surfaces and disabled opacity; feature and theme
|
||||||
palettes use `--gev-field-background`, `--gev-field-border-color`,
|
palettes use `--gev-field-background`, `--gev-field-border-color`,
|
||||||
@@ -759,11 +757,9 @@ field rules separate, so Radio's palette remains an intentional exception.
|
|||||||
|
|
||||||
### Read-only status badges
|
### Read-only status badges
|
||||||
|
|
||||||
`src/ui/statusBadge.js` constructs/updates ordinary badges without listeners,
|
Views draw read-only badges with the `gev-status-badge` classes, without
|
||||||
status inference or live-region policy. It consumes translated text and controlled
|
listeners, status inference or live-region policy. Appearance variants share
|
||||||
visibility; appearance variants share CSS rather than mapping machine states to
|
CSS rather than mapping machine states to universal severity. CCTV owns
|
||||||
universal severity. CCTV owns frame/source and calibration rules and their data
|
frame/source and calibration rules and their data attributes, while Weather
|
||||||
attributes, while Weather owns coverage labels. The keyed rail-card owner keeps
|
owns coverage labels. The rail card keeps node identity, header focus,
|
||||||
node identity, header focus, disclosure and teardown. The shared adapter never
|
disclosure and teardown, and does not announce every card update.
|
||||||
creates another state store or announces all card updates. A Vue view can replace
|
|
||||||
this adapter and retain these presentation inputs and styles.
|
|
||||||
+296
-108
@@ -4,6 +4,24 @@ Docker Compose mounts the checkout with the shared SELinux `:z` label so
|
|||||||
containers can read it on enforcing SELinux hosts. The separate image-owned
|
containers can read it on enforcing SELinux hosts. The separate image-owned
|
||||||
`node_modules` volume and loopback-only port publishing remain unchanged.
|
`node_modules` volume and loopback-only port publishing remain unchanged.
|
||||||
|
|
||||||
|
The HUD's AI summary line looks up place names for the view only when the
|
||||||
|
summary can use them. The page asks `GET /api/openai/hud-summary/status` once;
|
||||||
|
a server without an OpenAI key answers `{ configured: false }`, and the HUD then
|
||||||
|
makes no reverse-geocode or Nearby Places request until the page reloads. A
|
||||||
|
server without the route is asked once, and an unconfigured summary reply
|
||||||
|
stops the lookups the same way. Each lookup reverse-geocodes at most two
|
||||||
|
points: the view center alone below 10 km, the center and one viewport point
|
||||||
|
up to 750 km, and two viewport points up to 3000 km; Nearby Places is still
|
||||||
|
added below 25 km. A lookup is reused until the view center moves a quarter of
|
||||||
|
the visible width, the camera's distance to the center doubles or halves, or
|
||||||
|
the camera crosses one of those heights. The width is measured from the
|
||||||
|
camera's distance to the view center, so terrain and tilt scale it. A reused
|
||||||
|
lookup asks again only for what it did not get (a timeout, a failure, an empty
|
||||||
|
answer); everything else is a cache hit. A small pan over a reused lookup
|
||||||
|
repaints the last AI reply instead of asking for a new one. A host passing
|
||||||
|
`cachedOnly` keeps reading every cached sample. Voice context lookups are
|
||||||
|
unchanged.
|
||||||
|
|
||||||
The HUD's LAT/LON degrees-minutes-seconds readout rounds the coordinate once, to
|
The HUD's LAT/LON degrees-minutes-seconds readout rounds the coordinate once, to
|
||||||
hundredths of a second, and then splits it. A value within 0.005" of the next
|
hundredths of a second, and then splits it. A value within 0.005" of the next
|
||||||
whole minute carries into the minutes and degrees, so `-122.35` reads
|
whole minute carries into the minutes and degrees, so `-122.35` reads
|
||||||
@@ -179,6 +197,9 @@ catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
|
|||||||
Aircraft altitude-band queries require a reported barometric or geometric altitude
|
Aircraft altitude-band queries require a reported barometric or geometric altitude
|
||||||
for each match, so a grounded contact that reports none is left out of a band too.
|
for each match, so a grounded contact that reports none is left out of a band too.
|
||||||
Queries without an altitude band retain unknown-altitude contacts.
|
Queries without an altitude band retain unknown-altitude contacts.
|
||||||
|
`find_submarine_cables` lists and counts each cable once: TeleGeography splits
|
||||||
|
a few cables (2Africa, Echo) into several features under one id, and such a
|
||||||
|
cable matches when any of its parts crosses the area.
|
||||||
|
|
||||||
Catalog calls check their cancellation signal immediately before executing a
|
Catalog calls check their cancellation signal immediately before executing a
|
||||||
tool. Already-cancelled queries do not start; cancellation after execution starts
|
tool. Already-cancelled queries do not start; cancellation after execution starts
|
||||||
@@ -203,7 +224,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
|
changes preserve both settings; share links and scenes restore them. The
|
||||||
historical missing-`scf` fallback remains 35%; explicit 0% keeps the hard edge.
|
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
|
at 70% opacity behind telemetry, vertical metadata and scene chrome. Original
|
||||||
HUD text color, glow and opacity are preserved. Scope OFF brightens the tagline
|
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
|
and Active Style label with the shared secondary text color. The title plate
|
||||||
@@ -213,6 +234,15 @@ the original HUD presentation; Cyber retains its existing skin.
|
|||||||
`node scripts/qa-scope-hud.mjs` checks native input, range, layout persistence,
|
`node scripts/qa-scope-hud.mjs` checks native input, range, layout persistence,
|
||||||
restoration and both Scope presentations. `QA_SCREENSHOT_DIR` saves screenshots.
|
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
|
## Cyber HUD — September 30, 2026
|
||||||
|
|
||||||
Display > HUD > Layout includes Cyber, also available through the HUD voice
|
Display > HUD > Layout includes Cyber, also available through the HUD voice
|
||||||
@@ -228,8 +258,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
|
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
|
launchers available. Display, CCTV and Context scroll their contents inside fixed
|
||||||
headers and decorative frames. The narrow-screen rail remains scrollable to reach
|
headers and decorative frames. The narrow-screen rail remains scrollable to reach
|
||||||
each panel. Radio retains the shared nested Context player and compact
|
each panel. Radio retains the shared nested Context player without relocating
|
||||||
header disclosure, without relocating playback controls on theme changes.
|
playback controls on theme changes.
|
||||||
|
|
||||||
Voice help/error popups and Location/Visual Presets pins extend beyond their
|
Voice help/error popups and Location/Visual Presets pins extend beyond their
|
||||||
Cyber frames without being clipped: the shapes decorate non-interactive
|
Cyber frames without being clipped: the shapes decorate non-interactive
|
||||||
@@ -448,6 +478,33 @@ not worldwide detections; units are strikes/km²/min ×10³. It is neither raw G
|
|||||||
flashes nor a live strike counter. Weather imagery orders scalar context below
|
flashes nor a live strike counter. Weather imagery orders scalar context below
|
||||||
satellite, radar and lightning consistently across enable and history order.
|
satellite, radar and lightning consistently across enable and history order.
|
||||||
|
|
||||||
|
A weather source can describe its own products (credit, units, coverage,
|
||||||
|
legend) through an optional `display` object in each product manifest:
|
||||||
|
`source` (≤60 characters, the row's source name), `credit` (≤200, the imagery
|
||||||
|
credit in Data attribution, shown as plain text and updated when a refreshed
|
||||||
|
manifest changes it), `units` (≤40), `coverage` (≤48, the coverage badge and
|
||||||
|
info line), `info` (≤600, the info tooltip), `legend` (2–16 `{ label, color }`
|
||||||
|
stops; label a plain decimal number or text, at most 16 characters either way,
|
||||||
|
a number reading back exactly as the legend shows it: within ±2^53−1, with
|
||||||
|
no leading zeros and no digits lost to rounding; color `#rrggbb`) and `extent`
|
||||||
|
(`{ west, south, east, north }` degrees, non-empty; west greater than east
|
||||||
|
crosses the antimeridian; used for coverage navigation and the
|
||||||
|
outside-coverage status). `units` and `legend` describe one scale and
|
||||||
|
apply only together: if either is absent or invalid, both keep the built-in
|
||||||
|
values, and the radar info heading names the units in use. Text must be
|
||||||
|
single-line, without control characters (U+0000–U+001F, U+007F–U+009F), line
|
||||||
|
or paragraph separators, or invisible and format characters: soft hyphen
|
||||||
|
(U+00AD), combining grapheme joiner (U+034F), Arabic letter mark (U+061C),
|
||||||
|
Mongolian vowel separator (U+180E), zero-width and direction marks
|
||||||
|
(U+200B–U+200F), bidi embedding and override (U+202A–U+202E), word joiner,
|
||||||
|
invisible operators and bidi isolates (U+2060–U+2069), variation selectors
|
||||||
|
(U+FE00–U+FE0F), byte-order mark (U+FEFF) and tag characters
|
||||||
|
(U+E0000–U+E007F). Each other field is validated on its own; an absent or
|
||||||
|
invalid field keeps the built-in value, so the bundled nowCOAST manifests,
|
||||||
|
which carry no `display`, look exactly as before. Accepted source
|
||||||
|
and units text is source data and is shown untranslated. The static Data
|
||||||
|
attribution entries remain the application's `credits` list.
|
||||||
|
|
||||||
Cyclones combine fixed NHC/CPHC status and summary GIS endpoints through a bounded
|
Cyclones combine fixed NHC/CPHC status and summary GIS endpoints through a bounded
|
||||||
same-origin provider. The status endpoint lacks browser CORS; each installation
|
same-origin provider. The status endpoint lacks browser CORS; each installation
|
||||||
fetches keyless public sources at runtime, with no centralized ingestion service.
|
fetches keyless public sources at runtime, with no centralized ingestion service.
|
||||||
@@ -503,7 +560,9 @@ those reports arrive as 102.3 knots and 360 degrees. Both are stored as unknown,
|
|||||||
and vessel cards, the HUD and analyst queries show a missing measurement rather
|
and vessel cards, the HUD and analyst queries show a missing measurement rather
|
||||||
than a reading. Heading keeps its existing separate check for its own sentinel.
|
than a reading. Heading keeps its existing separate check for its own sentinel.
|
||||||
Genuine readings are unchanged, including a stopped vessel's zero and the
|
Genuine readings are unchanged, including a stopped vessel's zero and the
|
||||||
highest speed and course the fields can encode.
|
highest speed and course the fields can encode. Vessel cards and the HUD wrap
|
||||||
|
the direction after rounding to whole degrees, so a course of 359.5° or more
|
||||||
|
reads `0°` (`0DEG` in the HUD) rather than `360°`.
|
||||||
|
|
||||||
For an already tracked vessel, AISStream static messages update name, type,
|
For an already tracked vessel, AISStream static messages update name, type,
|
||||||
destination and IMO without changing its position, motion, fix timestamps,
|
destination and IMO without changing its position, motion, fix timestamps,
|
||||||
@@ -517,7 +576,7 @@ Native `<select>` controls declare a dark colour scheme and explicit option
|
|||||||
colours, so the browser-painted option list matches the panel it drops out of.
|
colours, so the browser-painted option list matches the panel it drops out of.
|
||||||
The scheme is declared on the controls rather than on the document root, so
|
The scheme is declared on the controls rather than on the document root, so
|
||||||
hand-tuned scrollbar and text-input skins are unchanged. Applies to the HUD
|
hand-tuned scrollbar and text-input skins are unchanged. Applies to the HUD
|
||||||
layout, Scenes, CCTV camera, Radio filter and Draw colour menus.
|
layout, Authoring, CCTV camera, Radio filter and Draw colour menus.
|
||||||
|
|
||||||
Vessel snapshot completeness is separate from freshness. A current snapshot with
|
Vessel snapshot completeness is separate from freshness. A current snapshot with
|
||||||
rejected or duplicate records shows PARTIAL with accepted/received counts; stale
|
rejected or duplicate records shows PARTIAL with accepted/received counts; stale
|
||||||
@@ -560,6 +619,11 @@ dateline while preserving both authored endpoint longitudes, and turns heading
|
|||||||
and roll the short way for any authored angles.
|
and roll the short way for any authored angles.
|
||||||
A seek to a shot's start, as a shot action makes, selects that shot rather than
|
A seek to a shot's start, as a shot action makes, selects that shot rather than
|
||||||
the end of the previous one.
|
the end of the previous one.
|
||||||
|
Same-shot seeking restores declared layer visibility after a manual toggle,
|
||||||
|
including one still in flight, by reconciling only the changed layers through
|
||||||
|
the cancellable layer admission path. Refused restoration reports a failed
|
||||||
|
seek and keeps the last settled position. Undeclared operator layers remain
|
||||||
|
untouched, and the shot is not rebuilt.
|
||||||
See [camera directions](DIRECTOR-CAMERA.md).
|
See [camera directions](DIRECTOR-CAMERA.md).
|
||||||
|
|
||||||
Realtime voice composes separate connection, response/tool, Radio handoff, input
|
Realtime voice composes separate connection, response/tool, Radio handoff, input
|
||||||
@@ -583,7 +647,7 @@ from revealing its UI. Ambiguous requests to open settings ask whether the user
|
|||||||
means Voice Settings or Provider Settings before opening either panel. Named
|
means Voice Settings or Provider Settings before opening either panel. Named
|
||||||
Voice Settings applies directly; Provider Settings opens the existing local
|
Voice Settings applies directly; Provider Settings opens the existing local
|
||||||
provider dialog when available. Visual Presets targets the visual controls tray,
|
provider dialog when available. Visual Presets targets the visual controls tray,
|
||||||
while Scenes targets saved scenes. Selecting Preview does not enable a disabled
|
while Scenes (the Authoring panel) targets saved scenes. Selecting Preview does not enable a disabled
|
||||||
Preview preference. Unverified speech captions use the localized Recent
|
Preview preference. Unverified speech captions use the localized Recent
|
||||||
speech label; ordinary user captions retain YOU. Caption attribution does not
|
speech label; ordinary user captions retain YOU. Caption attribution does not
|
||||||
control tool dispatch.
|
control tool dispatch.
|
||||||
@@ -716,7 +780,7 @@ Nepal scene ownership follows the parameters prepared by the public layer manage
|
|||||||
|
|
||||||
Animated scene cards sample their optional scale, opacity and leader callbacks once per projected frame. A failed callback suppresses that frame and can recover on the next one; ordinary layers keep the shared projection path without animation evaluation.
|
Animated scene cards sample their optional scale, opacity and leader callbacks once per projected frame. A failed callback suppresses that frame and can recover on the next one; ordinary layers keep the shared projection path without animation evaluation.
|
||||||
|
|
||||||
Nepal Flood Incident is accessed through Scenes. Its flood and locator components remain registered for scene playback but are hidden from the Data Layers menu. Closing the event stops the active scene, including pending loads and media. The default catalog does not install a separate reconstruction scene; existing saved scenes remain untouched.
|
Nepal Flood Incident is accessed through Authoring. Its flood and locator components remain registered for scene playback but are hidden from the Data Layers menu. Closing the event stops the active scene, including pending loads and media. The default catalog does not install a separate reconstruction scene; existing saved scenes remain untouched.
|
||||||
|
|
||||||
Double-click a shot name to edit it inline. Enter or moving focus saves a nonempty name, Escape cancels, and saved names persist with the scene project.
|
Double-click a shot name to edit it inline. Enter or moving focus saves a nonempty name, Escape cancels, and saved names persist with the scene project.
|
||||||
|
|
||||||
@@ -730,7 +794,7 @@ Persistent media-led Nepal shots preserve their completed upstream flood path du
|
|||||||
|
|
||||||
Repeated map-stack requests retain the live imagery layer when its provider is unchanged. Scene handoffs keep loaded tiles while real source changes, switch cancellation, and tile-error fallbacks retain their existing behavior.
|
Repeated map-stack requests retain the live imagery layer when its provider is unchanged. Scene handoffs keep loaded tiles while real source changes, switch cancellation, and tile-error fallbacks retain their existing behavior.
|
||||||
|
|
||||||
Nepal Flood Incident is available in Scenes, with a 25-shot sequence, geographic labels, an event panel, scene scrubbing and shot playback. Comparison shots use Esri beneath Vantor imagery. Other Nepal shots use Google 3D when its tileset is available and fall back to Esri otherwise; without an ion token, the existing map stack uses Re:Earth terrain. Saved shots retain their authored camera and map preferences across keyed and keyless runtimes. The bundled pack contains historical 2021 and post-event 2026 imagery, not an isolated before/after measurement; the animated flood path is schematic, not a hazard model. Witness media opens or embeds original sources without bundled clips. See `public/events/bhote-koshi-2026/README.md` for provenance and non-commercial data terms.
|
Nepal Flood Incident is available in Authoring, with a 25-shot sequence, geographic labels, an event panel, scene scrubbing and shot playback. Comparison shots use Esri beneath Vantor imagery. Other Nepal shots use Google 3D when its tileset is available and fall back to Esri otherwise; without an ion token, the existing map stack uses Re:Earth terrain. Saved shots retain their authored camera and map preferences across keyed and keyless runtimes. The bundled pack contains historical 2021 and post-event 2026 imagery, not an isolated before/after measurement; the animated flood path is schematic, not a hazard model. Witness media opens or embeds original sources without bundled clips. See `public/events/bhote-koshi-2026/README.md` for provenance and non-commercial data terms.
|
||||||
|
|
||||||
Transit snapshot and selected-history reads use an explicit source interface. The standalone source selects the existing routes; rendering, playback and selection retain their existing owners. A portable request service owns registered-feed admission, bounded decoding, cache/revalidation, backoff and history; development and preview use the same thin adapter. Compatibility exports and source attribution are preserved.
|
Transit snapshot and selected-history reads use an explicit source interface. The standalone source selects the existing routes; rendering, playback and selection retain their existing owners. A portable request service owns registered-feed admission, bounded decoding, cache/revalidation, backoff and history; development and preview use the same thin adapter. Compatibility exports and source attribution are preserved.
|
||||||
|
|
||||||
@@ -793,6 +857,11 @@ rendered detection set. The legacy manager retains panel helper compatibility;
|
|||||||
registration sealing, restoration order and failed-transition rollback remain
|
registration sealing, restoration order and failed-transition rollback remain
|
||||||
unchanged.
|
unchanged.
|
||||||
|
|
||||||
|
Polled layer refreshes and the HUD's camera readout pause while the page is
|
||||||
|
hidden. An app embedded inline in a panel keeps both running, because the
|
||||||
|
panel's host can report the page as hidden while it is on screen. This is the
|
||||||
|
same rule the render loop follows (`keepPanelRendering` in `app/embed.js`).
|
||||||
|
|
||||||
Application scene setup owns request services, terrain/floor caches and annotation
|
Application scene setup owns request services, terrain/floor caches and annotation
|
||||||
lookup state. Controls, layers and voice share those owners, and cancellation
|
lookup state. Controls, layers and voice share those owners, and cancellation
|
||||||
clears caches and removes map-stack listeners. Standalone setup supplies the
|
clears caches and removes map-stack listeners. Standalone setup supplies the
|
||||||
@@ -829,7 +898,7 @@ The standalone FIRMS provider, source defaults and parsing behavior are unchange
|
|||||||
|
|
||||||
CLI tools and development launchers accept `GEV_PROJECT_ROOT` to select the project environment, dependencies and output directory. Their default remains this checkout; bundled tool resources and source checks stay relative to the script installation. The setup doctor also accepts an explicit `rootDir`.
|
CLI tools and development launchers accept `GEV_PROJECT_ROOT` to select the project environment, dependencies and output directory. Their default remains this checkout; bundled tool resources and source checks stay relative to the script installation. The setup doctor also accepts an explicit `rootDir`.
|
||||||
|
|
||||||
Application composition now uses shared scene/control/catalog/tool constructors and allowlisted HTML component templates. The standalone entry supplies settings and the default sources. Terrain, boundaries, weather, regional context and summaries use configurable services; renderer/action owners remain page-scoped. Annotation proximity guards remain on by default and allow distant targets only for explicit navigation.
|
Application composition now uses shared scene/control/catalog/tool constructors and the application's Vue views (see [VUE-MIGRATION.md](VUE-MIGRATION.md)). The standalone entry supplies settings and the default sources. Terrain, boundaries, weather, regional context and summaries use configurable services; renderer/action owners remain page-scoped. Annotation proximity guards remain on by default and allow distant targets only for explicit navigation.
|
||||||
|
|
||||||
Voice controls compose a supplied action runner and connection controller.
|
Voice controls compose a supplied action runner and connection controller.
|
||||||
Realtime token and SDP requests live in a configurable backend, with independent
|
Realtime token and SDP requests live in a configurable backend, with independent
|
||||||
@@ -894,7 +963,9 @@ change. Once the camera is still and tiles have settled, each overlay anchor is
|
|||||||
replaced once by the rendered surface under it (two depth samples per paint,
|
replaced once by the rendered surface under it (two depth samples per paint,
|
||||||
accepted only within -30/+40 m of the floor cell), so badges do not float or
|
accepted only within -30/+40 m of the floor cell), so badges do not float or
|
||||||
sink on slopes; wedge corners follow their own floor cells instead of staying
|
sink on slopes; wedge corners follow their own floor cells instead of staying
|
||||||
level with the apex. Unknown bearings never acquire a wedge. The row reads
|
level with the apex. Unknown bearings never acquire a wedge. The selected
|
||||||
|
card's `DIRECTION` wraps after rounding to whole degrees, so a mapped bearing
|
||||||
|
of 359.5° or more reads `0°` rather than `360°`. The row reads
|
||||||
`N nearby · M on screen`; when every loaded camera is outside the view it adds
|
`N nearby · M on screen`; when every loaded camera is outside the view it adds
|
||||||
"None on screen — nearby cameras are outside the view" instead of looking
|
"None on screen — nearby cameras are outside the view" instead of looking
|
||||||
unloaded. `scripts/qa-alpr-journey.mjs` drives an Austin journey with ALPR off,
|
unloaded. `scripts/qa-alpr-journey.mjs` drives an Austin journey with ALPR off,
|
||||||
@@ -941,6 +1012,15 @@ directory. Every `/api/cctv` route refuses cross-site browser requests but
|
|||||||
accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded
|
accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded
|
||||||
request skips the key-bearing Street View fallback and gets the placeholder.
|
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 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
|
Traffic and bikeshare expose factories through `./layers/traffic` and
|
||||||
@@ -1058,7 +1138,8 @@ The tracked-aircraft descriptor both flight layers hand to the cockpit carries
|
|||||||
`verticalRate` and military records as `verticalRateMps`; the query seam maps both to
|
`verticalRate` and military records as `verticalRateMps`; the query seam maps both to
|
||||||
one name, so `resolveTrackedAircraftInfo` returns a single shape for either layer. The
|
one name, so `resolveTrackedAircraftInfo` returns a single shape for either layer. The
|
||||||
rate is reported, never derived: a blank poll keeps the last transmitted value and an
|
rate is reported, never derived: a blank poll keeps the last transmitted value and an
|
||||||
absent rate stays null rather than reading as level flight.
|
absent rate stays null rather than reading as level flight. A military readsb row
|
||||||
|
without `baro_rate` reports its GNSS rate as `geom_rate`, which is used instead.
|
||||||
|
|
||||||
## Transponder emergency highlighting
|
## Transponder emergency highlighting
|
||||||
|
|
||||||
@@ -1144,6 +1225,23 @@ Shot selection updates the highlight without replacing the row, preserving
|
|||||||
native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately;
|
native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately;
|
||||||
late file and failed-action completions cannot update removed presentation.
|
late file and failed-action completions cannot update removed presentation.
|
||||||
|
|
||||||
|
The panel is titled Authoring and is laid out in four labelled steps: SCENE
|
||||||
|
(picker on its own row, NEW SCENE, DELETE), SHOTS (+ CAPTURE SHOT, UPDATE SHOT,
|
||||||
|
the numbered shot list and a select/rename hint), PREVIEW (PLAY, STOP, NEXT) and
|
||||||
|
SAVE & SHARE (EXPORT, IMPORT, RUN LOG, then the EDIT DETAILS / SHARE SCENE row).
|
||||||
|
Every button in the step rows has a tooltip, and the status line is a polite
|
||||||
|
live region. Outside playback a button that would do nothing is disabled:
|
||||||
|
DELETE and CAPTURE SHOT without a scene, UPDATE SHOT without a selected shot,
|
||||||
|
PLAY and NEXT when no scene in the project has a shot (both continue into the
|
||||||
|
following scenes). During playback the authoring and file actions stay disabled
|
||||||
|
and STOP is enabled, as before; RUN LOG follows the last run, and EDIT DETAILS /
|
||||||
|
SHARE SCENE keep their own checks. PLAY uses the `scene|PLAY` catalog entry,
|
||||||
|
apart from the radio's PLAY.
|
||||||
|
|
||||||
|
Director clock listeners, interaction observers and scene release failures no
|
||||||
|
longer leave the director inconsistent, and a phase error is kept when cleanup
|
||||||
|
also fails.
|
||||||
|
|
||||||
## Cockpit component ownership
|
## Cockpit component ownership
|
||||||
|
|
||||||
Cockpit presentation is separated from its camera/controller behavior. Existing
|
Cockpit presentation is separated from its camera/controller behavior. Existing
|
||||||
@@ -1360,8 +1458,10 @@ and sharpen. Display supplies actions and renders settings; effect execution has
|
|||||||
no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
|
no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
|
||||||
Stopping the controller revokes animation before asynchronous UI teardown; final
|
Stopping the controller revokes animation before asynchronous UI teardown; final
|
||||||
destruction removes its stages and restores the borrowed bloom configuration.
|
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
|
Snow is no longer a visual preset. A share link that names it, in either the
|
||||||
its amplitude.
|
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
|
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.
|
stage is disabled; raising the slider resumes it when sharpen is enabled.
|
||||||
|
|
||||||
@@ -1372,11 +1472,11 @@ The application supplies actions and retains effect settings, restore claims and
|
|||||||
rendering. Native keyboard editing, model modes and current defaults are preserved.
|
rendering. Native keyboard editing, model modes and current defaults are preserved.
|
||||||
Destroying the UI removes these listeners before asynchronous teardown.
|
Destroying the UI removes these listeners before asynchronous teardown.
|
||||||
|
|
||||||
Ordinary sliders share the native range component in `ui/slider.js` and the
|
Ordinary sliders share the native `gev-quantitative-slider` surface in
|
||||||
`gev-quantitative-slider` surface in `ui/styles/slider.css`. Display, shader
|
`ui/styles/slider.css`. Display, shader
|
||||||
parameters, volume, replay speed, weather settings/history, imagery opacity and
|
parameters, volume, replay speed, weather settings/history, imagery opacity and
|
||||||
Street View's date filter use the same rail, thumb, value styling, keyboard focus and disabled state.
|
Street View's date filter use the same rail, thumb, value styling, keyboard focus and disabled state.
|
||||||
Generated controls use `createSlider`; static templates apply the same class.
|
Templates, components and generated controls apply the same class.
|
||||||
Feature owners retain bounds, steps, formatted values, input/change timing and
|
Feature owners retain bounds, steps, formatted values, input/change timing and
|
||||||
listener cleanup. The radio tuning dial, imagery comparison divider and event
|
listener cleanup. The radio tuning dial, imagery comparison divider and event
|
||||||
comparison/progression controls keep their specialized presentation and behavior.
|
comparison/progression controls keep their specialized presentation and behavior.
|
||||||
@@ -1395,11 +1495,9 @@ boundaries preserve the current Tactical/Cyber appearance and native behavior.
|
|||||||
## Ordinary status badges
|
## Ordinary status badges
|
||||||
|
|
||||||
CCTV source/frame labels, calibration provenance and Weather coverage labels use
|
CCTV source/frame labels, calibration provenance and Weather coverage labels use
|
||||||
`ui/statusBadge.js` and the `gev-status-badge` CSS surface. The listener-free
|
the `gev-status-badge` CSS surface, drawn by the CCTV view and the rail card from
|
||||||
adapter accepts already-translated `label` and `hidden` values; pill, box and plain
|
already-translated labels; pill, box and plain appearances describe
|
||||||
appearances describe shape/density only. Static CCTV markup uses the same classes
|
shape/density only. Empty coverage remains hidden.
|
||||||
as generated rail-card badges. Repeated identical updates do not rewrite text or
|
|
||||||
replace nodes. Empty coverage remains hidden.
|
|
||||||
|
|
||||||
Feature owners retain the status rules, machine-state attributes, translations
|
Feature owners retain the status rules, machine-state attributes, translations
|
||||||
and announcement policy. CCTV frame acquisition (`idle/loading/error/ready`)
|
and announcement policy. CCTV frame acquisition (`idle/loading/error/ready`)
|
||||||
@@ -1409,18 +1507,16 @@ prior poses; unsaved edits override the label until save/reset. Coverage labels
|
|||||||
remain geographic scope, not health claims. Existing colors and the source
|
remain geographic scope, not health claims. Existing colors and the source
|
||||||
badge's polite live region are preserved; coverage and calibration do not gain
|
badge's polite live region are preserved; coverage and calibration do not gain
|
||||||
live regions. Interactive layer toggles, progress indicators and world overlays
|
live regions. Interactive layer toggles, progress indicators and world overlays
|
||||||
retain their specialized ownership. Vue can reuse the view contract and CSS while
|
retain their specialized ownership. Browser acceptance:
|
||||||
replacing DOM construction. Browser acceptance:
|
|
||||||
`QA_BASE_URL=http://127.0.0.1:4189 node scripts/qa-status-badges.mjs`.
|
`QA_BASE_URL=http://127.0.0.1:4189 node scripts/qa-status-badges.mjs`.
|
||||||
|
|
||||||
## Ordinary action buttons
|
## Ordinary action buttons
|
||||||
|
|
||||||
Scene panel actions, shot LOAD/DEL actions, the EDIT DETAILS / SHARE SCENE
|
Scene panel actions, shot LOAD/DEL actions, the EDIT DETAILS / SHARE SCENE
|
||||||
bar and one-shot CCTV actions use `ui/actionButton.js` and the shared
|
bar and one-shot CCTV actions use the shared `gev-action-button` surface in
|
||||||
`gev-action-button` surface in `ui/styles/action-button.css`. Static templates
|
`ui/styles/action-button.css`, with default/danger variants and
|
||||||
apply the same classes as generated buttons. The native builder accepts translated
|
default/compact sizes, drawn by the scene panels view (`LayerPanelsView.vue`,
|
||||||
text, default/danger variants, default/compact sizes and disabled/accessibility
|
`SceneShotList.vue`) and the CCTV panel. Feature
|
||||||
properties; it installs no listeners and owns no application state. Feature
|
|
||||||
controllers retain callbacks, enable/disable rules, confirmation prompts,
|
controllers retain callbacks, enable/disable rules, confirmation prompts,
|
||||||
persistence and teardown. The smaller shot actions retain their existing sizing
|
persistence and teardown. The smaller shot actions retain their existing sizing
|
||||||
and hover treatment. Feature CSS still owns panel layout and translated wrapping.
|
and hover treatment. Feature CSS still owns panel layout and translated wrapping.
|
||||||
@@ -1428,21 +1524,21 @@ and hover treatment. Feature CSS still owns panel layout and translated wrapping
|
|||||||
CCTV enable, coverage, auto-hop, projection and adjustment toggles, editable
|
CCTV enable, coverage, auto-hop, projection and adjustment toggles, editable
|
||||||
calibration-value chips and the scene authoring dialogs retain their specialized
|
calibration-value chips and the scene authoring dialogs retain their specialized
|
||||||
controls. Existing `scene-btn` hooks remain compatible with Street Level and
|
controls. Existing `scene-btn` hooks remain compatible with Street Level and
|
||||||
legacy toggles. The shared CSS, explicit input/action boundary and behavior tests
|
legacy toggles.
|
||||||
can carry to a Vue view while Vue replaces native DOM construction.
|
|
||||||
Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4179 node scripts/qa-action-buttons.mjs`;
|
Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4179 node scripts/qa-action-buttons.mjs`;
|
||||||
`qa-scene-controls.mjs` also covers real Scene authoring/playback with native Space capture.
|
`qa-scene-controls.mjs` also covers real Scene authoring/playback with native Space capture.
|
||||||
|
|
||||||
## Segmented selectors
|
## Segmented selectors
|
||||||
|
|
||||||
Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js`
|
Segmented selectors are the kit's `UiSegmented` (`ui/kit/UiSegmented.vue`) on
|
||||||
and the shared `gev-segmented` / `gev-segment` surface. Model coverage, label
|
the shared `gev-segmented` / `gev-segment` surface. Model coverage, label
|
||||||
allocation, drawing shape, Street View fit/panorama filters and imagery mode keep
|
allocation, drawing shape, Street View fit/panorama filters and imagery mode keep
|
||||||
their feature-owned values, actions and persistence. Painting a restored value
|
their feature-owned values, actions and persistence: the view renders the
|
||||||
does not emit a change or move focus. Existing keyboard policies remain explicit:
|
owner's value and sends a choice back through its store. Rendering a restored
|
||||||
Street View uses arrows/Home/End with one tab stop, imagery mode uses arrows from
|
value does not emit a change or move focus. Each group names its keyboard
|
||||||
its controlled selection, and Display keeps its existing native button behavior.
|
policy: Street View uses `radio` (arrows/Home/End with one tab stop), imagery
|
||||||
Pure value navigation is separate from DOM rendering for reuse by a future view.
|
mode `arrows` from its controlled selection, and Display `none`, its native
|
||||||
|
button behavior.
|
||||||
Segments keep their word minimum width: compact inline groups such as FIT/FILL
|
Segments keep their word minimum width: compact inline groups such as FIT/FILL
|
||||||
and ALL/360°/FLAT must not break short labels mid-word. Collapsed Weather follows
|
and ALL/360°/FLAT must not break short labels mid-word. Collapsed Weather follows
|
||||||
the same exclusive-rail hiding rule as the other readouts.
|
the same exclusive-rail hiding rule as the other readouts.
|
||||||
@@ -1476,18 +1572,40 @@ Automatic collapse remains presentation only, prioritizes the latest explicit
|
|||||||
panel, and honors keyboard focus on the right. Mobile layout still releases
|
panel, and honors keyboard focus on the right. Mobile layout still releases
|
||||||
desktop allocation styles. Stable Display allocation avoids unnecessary style
|
desktop allocation styles. Stable Display allocation avoids unnecessary style
|
||||||
writes. This extraction does not change panel positions or layout defaults.
|
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
|
The right rail skips panels hidden by other CSS (computed `display: none`), but
|
||||||
keeps the collapsed panels its own exclusive presentation hides, so automatic
|
keeps the collapsed panels its own exclusive presentation hides, so automatic
|
||||||
collapse can still be undone.
|
collapse can still be undone.
|
||||||
|
|
||||||
|
## Panel rails
|
||||||
|
|
||||||
|
On desktop each side stack has a floating icon rail (`src/ui/panelNavigation.js`,
|
||||||
|
rendered by the Vue `PanelRailView` and the kit's `UiRail`) in the screen-edge
|
||||||
|
gutter beside it. The rail lists every panel the side currently offers, in stack
|
||||||
|
order: Data Layers and Authoring on the left; Display, CCTV, Weather and Recent
|
||||||
|
Imagery (while shown), Street Level and Context on the right. Selecting an item
|
||||||
|
opens its panel through `setPanelCollapsed(id, false, {explicit: true})`;
|
||||||
|
selecting the open panel's item closes it. The item of the open panel is
|
||||||
|
pressed (`aria-pressed`), and its tooltip is suppressed while the panel is open.
|
||||||
|
While no panel on its side is open, the rail shows each item as a labelled pill
|
||||||
|
(icon and title, `UiRail`'s `collapsible`); opening a panel folds the labels away
|
||||||
|
to the icon rail. The rail is placed by its screen-side edge, so the pills grow
|
||||||
|
toward the middle of the screen, over the closed side's empty corridor.
|
||||||
|
|
||||||
|
One panel is open per desktop side, in every HUD layout. Opening a side panel
|
||||||
|
by any route (rail, header, keyboard, voice) collapses its peers; restored or
|
||||||
|
shared state that opens several is normalized by the layout pass, which keeps
|
||||||
|
the most recently opened panel, then the one holding keyboard focus, then the
|
||||||
|
first, and marks the others `accordion-collapsed` (presentation only, so share
|
||||||
|
links still report the saved preference). Collapsed panels are hidden
|
||||||
|
(`layout-exclusive`) because the rail lists them. The rail lives inside its
|
||||||
|
stack, so it hides with it in Cockpit (where Authoring's item is also hidden),
|
||||||
|
recording and clean view. The panel store (`src/ui/stores/panels.js`) mirrors
|
||||||
|
each side's panels from the DOM; `PanelChrome` remains the disclosure owner.
|
||||||
|
|
||||||
## Phone layout
|
## Phone layout
|
||||||
|
|
||||||
At 720px and narrower, `src/ui/mobileShell.js` keeps the globe full screen and
|
At 720px and narrower, `src/ui/mobileShell.js` keeps the globe full screen and
|
||||||
shows a bottom tab bar: Layers (Data Layers, with Scenes collapsed below),
|
shows a bottom tab bar: Layers (Data Layers, with Authoring collapsed below),
|
||||||
Cameras (CCTV and Street Level), Context (Global Context, plus Weather and
|
Cameras (CCTV and Street Level), Context (Global Context, plus Weather and
|
||||||
Recent Imagery when shown), Search (Location) and More (Visual Presets and Map
|
Recent Imagery when shown), Search (Location) and More (Visual Presets and Map
|
||||||
Source above Display). A tab opens one bottom sheet; the same tab, the backdrop
|
Source above Display). A tab opens one bottom sheet; the same tab, the backdrop
|
||||||
@@ -1518,9 +1636,10 @@ panel the user changed inside the sheet stays as they left it. Escape closes the
|
|||||||
sheet before a panel's own Escape handling, and the dock's hover auto-close does
|
sheet before a panel's own Escape handling, and the dock's hover auto-close does
|
||||||
not apply inside a sheet.
|
not apply inside a sheet.
|
||||||
Cockpit, recording and clean view close the sheet and hide the tab bar; scene
|
Cockpit, recording and clean view close the sheet and hide the tab bar; scene
|
||||||
playback keeps both so its STOP control in the Scenes panel stays reachable. The
|
playback keeps both so its STOP control in the Authoring panel stays reachable. The
|
||||||
right rail neither hides collapsed siblings nor applies Cyber's one-panel
|
side rails neither hide collapsed siblings nor apply the desktop one-panel
|
||||||
accordion at these widths, since the sheet chooses what shows. The Cesium and Google attribution stays visible above the
|
accordion at these widths, and the panel rails are hidden, since the sheet
|
||||||
|
chooses what shows. The Cesium and Google attribution stays visible above the
|
||||||
voice control, and above an open sheet. The page draws under the notch and
|
voice control, and above an open sheet. The page draws under the notch and
|
||||||
system bars (`viewport-fit=cover`) and the shell's chrome uses safe-area insets.
|
system bars (`viewport-fit=cover`) and the shell's chrome uses safe-area insets.
|
||||||
Wider viewports are unchanged, and crossing to them closes any open sheet.
|
Wider viewports are unchanged, and crossing to them closes any open sheet.
|
||||||
@@ -1968,7 +2087,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
|
rotation and mission-marker highlight as pointer hover. Focus alone does not
|
||||||
select the mission; Enter or Space performs selection. Keyboard and pointer
|
select the mission; Enter or Space performs selection. Keyboard and pointer
|
||||||
preview ownership remain independent when the pointer is parked over the list.
|
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
|
Sites, and Clear Selected Layers remain focusable while lifecycle work is in
|
||||||
progress. They announce busy/disabled semantics and ignore repeat activation
|
progress. They announce busy/disabled semantics and ignore repeat activation
|
||||||
until the operation settles, so async work cannot drop the keyboard ring.
|
until the operation settles, so async work cannot drop the keyboard ring.
|
||||||
@@ -2066,6 +2185,19 @@ throws, that source reports failure without a contradictory success entry.
|
|||||||
The existing per-record append continues to support large feeds; sequential
|
The existing per-record append continues to support large feeds; sequential
|
||||||
fetching, trailing-24-hour filtering and partial-success caching are unchanged.
|
fetching, trailing-24-hour filtering and partial-success caching are unchanged.
|
||||||
|
|
||||||
|
FIRMS requests retry once over IPv4 when every resolved address fails to
|
||||||
|
connect. Node's network-family auto-selection gives each address a short
|
||||||
|
attempt timeout, so on hosts where IPv6 is unreachable a slow IPv4 handshake
|
||||||
|
was abandoned and the request reported only `fetch failed` (#68). The retry
|
||||||
|
makes a single IPv4 connection within the same deadline and the same body
|
||||||
|
cap, and does not follow redirects. HTTP errors, an expired request deadline
|
||||||
|
and DNS failures are not retried; a per-address connect timeout, which Node 26
|
||||||
|
reports as `ConnectTimeoutError`, is. A retry answered with a null-body status
|
||||||
|
(204, 205, 304) or a status above 599 fails that source instead of crashing
|
||||||
|
the server, and a non-OK response releases its connection at once. FIRMS
|
||||||
|
warnings include the per-address connection errors and never the request URL,
|
||||||
|
which embeds the MAP_KEY.
|
||||||
|
|
||||||
## Installations and map-source guidance
|
## Installations and map-source guidance
|
||||||
|
|
||||||
- Keyless mapped installations use OpenFreeMap `landuse` military polygons,
|
- Keyless mapped installations use OpenFreeMap `landuse` military polygons,
|
||||||
@@ -2214,8 +2346,9 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged.
|
|||||||
> to yield to: whoever handles ESC first calls `preventDefault()` **and**
|
> to yield to: whoever handles ESC first calls `preventDefault()` **and**
|
||||||
> `stopImmediatePropagation()`, and the launcher skips `defaultPrevented` events.
|
> `stopImmediatePropagation()`, and the launcher skips `defaultPrevented` events.
|
||||||
> `stopPropagation()` alone does **not** stop later listeners on the same
|
> `stopPropagation()` alone does **not** stop later listeners on the same
|
||||||
> `document` — that is exactly how the compact Radio disclosure made one key
|
> `document` — that is exactly how a disclosure that called it alone made one
|
||||||
> close the disclosure _and_ dismiss the launcher.
|
> 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
|
> **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
|
> load, with no timeout. None of the four classes is restored at startup, so an
|
||||||
@@ -2254,6 +2387,16 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged.
|
|||||||
> SCOPE toggle + FEATHER slider, hash keys `sc`/`scf`) — it is NOT the
|
> SCOPE toggle + FEATHER slider, hash keys `sc`/`scf`) — it is NOT the
|
||||||
> six zero-intensity style stages anymore (those are disabled; see the
|
> six zero-intensity style stages anymore (those are disabled; see the
|
||||||
> history note in `_initStages`). Hidden tabs stop the render loop.
|
> history note in `_initStages`). Hidden tabs stop the render loop.
|
||||||
|
> AIS vessels no longer hold the loop while enabled (2026-09-30): vessels move
|
||||||
|
> only when a poll lands, so the layer requests a frame for every forced pass
|
||||||
|
> (new data, selection), leaves one trailing frame when the camera moved inside
|
||||||
|
> the 800 ms visibility cadence, and holds `ais-vessels` only while focus
|
||||||
|
> emphasis animates or any vessel stays dimmed under a live focus target. The
|
||||||
|
> trailing frame goes through the layer's injectable runtime clock and is
|
||||||
|
> cancelled on disable, destroy and reset (`renderGovernor.test.mjs` pins the
|
||||||
|
> no-self-wake invariant). The 80 ms focus pass also skips hidden sprites whose
|
||||||
|
> focus state is at rest (`spriteFocusAtRest`), since advancing them changes
|
||||||
|
> nothing; with a 12k worldwide feed that is most of the fleet.
|
||||||
> The scope's OUTSIDE terminus is **altitude-adaptive** (2026-08-17): 0.94
|
> The scope's OUTSIDE terminus is **altitude-adaptive** (2026-08-17): 0.94
|
||||||
> at/above **10 Mm**, so
|
> at/above **10 Mm**, so
|
||||||
> faint stars survive in the corners of a TRUE full-globe view, fading quickly
|
> faint stars survive in the corners of a TRUE full-globe view, fading quickly
|
||||||
@@ -2789,7 +2932,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> failed searches leave the current camera owner untouched, and Context Focus
|
> failed searches leave the current camera owner untouched, and Context Focus
|
||||||
> can return to the preserved Contact. Focus also restores the selected
|
> can return to the preserved Contact. Focus also restores the selected
|
||||||
> aircraft's canonical follow frame after a manual zoom-away.
|
> 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
|
> Configurable preset selection—including same-style reselection—opens the
|
||||||
> shared Parameters surface directly below Detection and scrolls it into
|
> shared Parameters surface directly below Detection and scrolls it into
|
||||||
> view; share-link restoration
|
> view; share-link restoration
|
||||||
@@ -2841,7 +2984,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> Inside Cockpit, the focused summary card is titled Contact in both visible
|
> Inside Cockpit, the focused summary card is titled Contact in both visible
|
||||||
> copy and its accessible control labels.
|
> copy and its accessible control labels.
|
||||||
> The top-center Cockpit vision cycle is the fixed, duplicate-free sequence
|
> 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
|
> 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
|
> Normal remains available exactly once. NONE is not offered. Cockpit choices
|
||||||
> temporarily override the map preset, while Exit Cockpit and Reset both restore
|
> temporarily override the map preset, while Exit Cockpit and Reset both restore
|
||||||
@@ -2885,7 +3028,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> corridor). Its minimal Display popover exposes Intel HUD,
|
> corridor). Its minimal Display popover exposes Intel HUD,
|
||||||
> Detection, Parameters, and 3D aircraft. During Cockpit, those existing
|
> Detection, Parameters, and 3D aircraft. During Cockpit, those existing
|
||||||
> standard Display controls move into the Cockpit popover and retain their normal
|
> 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
|
> 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
|
> 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
|
> maintain a second control state. **Detection is owned by the CONTACTS
|
||||||
@@ -2940,22 +3083,17 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> sibling is also removed from the accessibility tree, and focus transfers to
|
> sibling is also removed from the accessibility tree, and focus transfers to
|
||||||
> the expanded utility if a layout transition hides the focused launcher. The
|
> the expanded utility if a layout transition hides the focused launcher. The
|
||||||
> shared map Display, Radio detail,
|
> shared map Display, Radio detail,
|
||||||
> ordinary Global Context, and Scenes surfaces stay hidden in Cockpit. Data
|
> ordinary Global Context, and Authoring surfaces stay hidden in Cockpit. Data
|
||||||
> Layers remains available. Its Cockpit-only stack paints
|
> Layers remains available. Its Cockpit-only stack paints
|
||||||
> above the curved speed and altitude rulers, matching the existing Context,
|
> above the curved speed and altitude rulers, matching the existing Context,
|
||||||
> Signals, Display, and Radio surface ordering without changing map-view
|
> Signals, Display, and Radio surface ordering without changing map-view
|
||||||
> stacking. Narrow mobile Cockpit
|
> stacking. Narrow mobile Cockpit
|
||||||
> viewports suppress the legacy layer stack, peripheral HUD, and secondary
|
> viewports suppress the legacy layer stack, peripheral HUD, and secondary
|
||||||
> Context/briefing panes so the flight instruments and primary controls remain
|
> Context/briefing panes so the flight instruments and primary controls remain
|
||||||
> unobstructed. Outside Cockpit, Radio starts collapsed while off. When Context
|
> unobstructed. Outside Cockpit, Radio starts collapsed while off and lives
|
||||||
> is collapsed, its Radio header icon opens compact controls whose explicit
|
> only in its section inside Context; the detailed panel's normal accordion
|
||||||
> Enable/Disable action owns power; stable close and full-panel buttons own
|
> control owns its disclosure and its Enable/Disable action owns power.
|
||||||
> compact dismissal and one-way detailed expansion independently. When Context
|
> Radio volume in the full and Cockpit surfaces, plus Space Mission
|
||||||
> 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
|
|
||||||
> replay speed, use Display / Sharpen's muted 3px rail, circular cyan thumb,
|
> replay speed, use Display / Sharpen's muted 3px rail, circular cyan thumb,
|
||||||
> glow, and mono value treatment. Their larger transparent hit areas, keyboard
|
> glow, and mono value treatment. Their larger transparent hit areas, keyboard
|
||||||
> focus indication, ranges, disabled states, and mission speed scale remain
|
> focus indication, ranges, disabled states, and mission speed scale remain
|
||||||
@@ -3078,7 +3216,14 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> usable per point, while an uncached absent height still returns 502 rather
|
> usable per point, while an uncached absent height still returns 502 rather
|
||||||
> than becoming a fabricated ground value. A position the upstream answers
|
> than becoming a fabricated ground value. A position the upstream answers
|
||||||
> with a null ellipsoid is reported as an absent height rather than a refresh
|
> with a null ellipsoid is reported as an absent height rather than a refresh
|
||||||
> failure, and is left uncached so a later poll re-asks it. Client geoid
|
> failure, and is left uncached so a later poll re-asks it. A `Retry-After`
|
||||||
|
> longer than the 10 s retry budget ends the retries at once. After a 429 that
|
||||||
|
> outlasts its retries, the proxy pauses upstream calls for the `Retry-After`
|
||||||
|
> (60 seconds when none, at most 10 minutes). A request whose points are all
|
||||||
|
> cached, fresh or stale, is still served; a request with any uncached point
|
||||||
|
> gets 502 and the page uses its geoid fallback for that chunk. Each request
|
||||||
|
> that was waiting on the upstream when the 429 came back logs one warning;
|
||||||
|
> requests refused during the pause do not log. Client geoid
|
||||||
> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the
|
> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the
|
||||||
> first later successful fetch. Smaller chunks reduce timeout risk; complete
|
> first later successful fetch. Smaller chunks reduce timeout risk; complete
|
||||||
> camera batches still wait for their sequential requests, and unresolved
|
> camera batches still wait for their sequential requests, and unresolved
|
||||||
@@ -3733,7 +3878,7 @@ test:track` 43 tracking invariants · headless QA harnesses under
|
|||||||
|
|
||||||
### Interface localization (October 2026)
|
### 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), **ar**, **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:
|
`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow:
|
||||||
[`docs/TRANSLATORS.md`](./TRANSLATORS.md).
|
[`docs/TRANSLATORS.md`](./TRANSLATORS.md).
|
||||||
|
|
||||||
@@ -3744,14 +3889,22 @@ Shipped locales: **en** (source and fallback), **es**, **ru**, **zh-CN** and **z
|
|||||||
value). English has no catalog; every other locale is
|
value). English has no catalog; every other locale is
|
||||||
`src/i18n/locales/<code>.json`, and a missing entry falls back to English.
|
`src/i18n/locales/<code>.json`, and a missing entry falls back to English.
|
||||||
- **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) →
|
- **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
|
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
|
it, so a recipient keeps their own language. The globe picker in the
|
||||||
bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops
|
bottom-right corner (bottom-left in Arabic) (`src/ui/localePicker.js`) stores the choice, drops
|
||||||
`?lang=` (keeps it only when storage is blocked) and reloads. It asks first
|
`?lang=` (keeps it only when storage is blocked) and switches in place:
|
||||||
when the reload would lose work registered with `registerUnsavedWork`:
|
views re-render, code that published translated text words it again through
|
||||||
unsaved keys or CCTV calibration, Draw marks, imported map files, the open
|
`onLocaleChange`, and `<html lang>`/`dir` and the page's `data-i18n` markup
|
||||||
cockpit, a voice session.
|
follow. Nothing is lost; a toast already on screen keeps its words.
|
||||||
|
- **Right to left.** Arabic (`ar`) is in `RTL_LOCALES`, so `<html dir="rtl">`:
|
||||||
|
the title, style indicator, both panel stacks and their icon rails swap
|
||||||
|
sides through logical properties (`inset-inline-*`, `margin-inline-*`), the
|
||||||
|
dock and corner row reverse their order, and `src/ui/styles/rtl.css` holds
|
||||||
|
the rest: no letter-spacing on Arabic text, Noto Sans Arabic in the font
|
||||||
|
stacks, the intel HUD kept as a left-to-right instrument overlay, FM seek
|
||||||
|
and the radio dial kept left to right. Intl formatters get `ar-u-nu-latn`,
|
||||||
|
so numbers and dates keep Western digits.
|
||||||
- **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`)
|
- **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`)
|
||||||
extracts every message and fails on missing, empty, unused,
|
extracts every message and fails on missing, empty, unused,
|
||||||
placeholder-mismatched, still-English (plural forms included) or
|
placeholder-mismatched, still-English (plural forms included) or
|
||||||
@@ -3811,7 +3964,7 @@ its criteria cannot be silently ignored.
|
|||||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||||
@@ -3851,7 +4004,7 @@ anchors, recovery, keyboard and mobile states; emergency repainting is covered
|
|||||||
by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
|
by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
|
||||||
|
|
||||||
|
|
||||||
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
||||||
|
|
||||||
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
|
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
|
||||||
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
||||||
@@ -4172,7 +4325,9 @@ animate). Disable clears all state.
|
|||||||
|
|
||||||
At the metre/kilometre boundary, route distance labels use the rounded
|
At the metre/kilometre boundary, route distance labels use the rounded
|
||||||
ten-metre value to select the unit, switching to kilometres at 995 metres;
|
ten-metre value to select the unit, switching to kilometres at 995 metres;
|
||||||
a 996-metre leg displays as `1.0 km` rather than `1000 m`.
|
a 996-metre leg displays as `1.0 km` rather than `1000 m`. Whole kilometres
|
||||||
|
start where the label would round to 10.0 km, so a 9.96 km route displays as
|
||||||
|
`10 km` rather than `10.0 km`.
|
||||||
|
|
||||||
**Keys needed: none.** Directions works on a keyless boot, and the layer is off
|
**Keys needed: none.** Directions works on a keyless boot, and the layer is off
|
||||||
until the operator turns it on.
|
until the operator turns it on.
|
||||||
@@ -4347,7 +4502,9 @@ to 0.45 at two minutes, 0.20 at ten minutes and 0.08 at fifteen minutes.
|
|||||||
display delay and newest-report age, reported speed/stop, vehicle ID, and
|
display delay and newest-report age, reported speed/stop, vehicle ID, and
|
||||||
available trail duration. Report age is rounded to whole seconds and stays in seconds while that rounded
|
available trail duration. Report age is rounded to whole seconds and stays in seconds while that rounded
|
||||||
count is below 90; otherwise it shows completed minutes calculated from the
|
count is below 90; otherwise it shows completed minutes calculated from the
|
||||||
rounded second count. Its anchor follows the rendered marker; text is
|
rounded second count. The reported heading wraps after rounding to whole
|
||||||
|
degrees, so a bearing of 359.5° or more reads `hdg 0°` rather than `hdg 360°`.
|
||||||
|
Its anchor follows the rendered marker; text is
|
||||||
checked at 4 Hz and published when it changes. DETECT labels retain route,
|
checked at 4 Hz and published when it changes. DETECT labels retain route,
|
||||||
mode and motion, with the selected label suppressed beside its fuller card.
|
mode and motion, with the selected label suppressed beside its fuller card.
|
||||||
Candidate membership changes on revisions, coalesced at 4 Hz; paints read
|
Candidate membership changes on revisions, coalesced at 4 Hz; paints read
|
||||||
@@ -4463,8 +4620,21 @@ silent for five minutes are removed. Credits appear when the feed is used.
|
|||||||
coordinator, but it is not a user-visible Data Layers entry. Its visible entry
|
coordinator, but it is not a user-visible Data Layers entry. Its visible entry
|
||||||
point is the right-side `CONTEXT` chooser's `CONTACTS` mode.
|
point is the right-side `CONTEXT` chooser's `CONTACTS` mode.
|
||||||
|
|
||||||
Contacts distance labels select metres or kilometres after rounding to the
|
Contacts distance labels, in the panel and on the globe's contact markers,
|
||||||
nearest metre, so a 999.5-metre contact displays as `1.0 km`, not `1000 m`.
|
select metres or kilometres after rounding to the nearest metre, so a
|
||||||
|
999.5-metre contact displays as `1.0 km`, not `1000 m`, and drop the
|
||||||
|
kilometre decimal from 9.95 km, so 9,960 m displays as `10 km`.
|
||||||
|
|
||||||
|
Distance labels that switch between metres and kilometres share
|
||||||
|
`distanceParts` / `formatDistanceText` in `src/data/distanceFormat.js`: the
|
||||||
|
Contacts panel and markers, Directions rows and step cards, placed annotation
|
||||||
|
labels, the drawn-line measure and `formatRouteDistance`. Each cut is taken on
|
||||||
|
the value as it prints, and kilometres come back already rounded, so the
|
||||||
|
plain and localized labels agree. Callers choose the metre step (whole metres;
|
||||||
|
tens, as annotation labels use; or tens with whole metres under 100, as
|
||||||
|
Directions uses) and keep their own rendering; the Cockpit's kilometre-only
|
||||||
|
distance uses `kilometreDigits`. The HUD altitude tag and the spoken view
|
||||||
|
height still choose their own unit.
|
||||||
|
|
||||||
At global scale, ambient Radio cluster badges are hard-opacity shared-host
|
At global scale, ambient Radio cluster badges are hard-opacity shared-host
|
||||||
entries: count/category updates and identity replacement do not run keyhole or
|
entries: count/category updates and identity replacement do not run keyhole or
|
||||||
@@ -4724,13 +4894,6 @@ synchronous playback-control observers run, so reentrant voice cleanup cannot
|
|||||||
overwrite a released stream with a stale paused state. Layer
|
overwrite a released stream with a stale paused state. Layer
|
||||||
destruction likewise retains an enabled manager entry when module disable or
|
destruction likewise retains an enabled manager entry when module disable or
|
||||||
destroy fails semantically, preventing an active orphan and allowing retry.
|
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.
|
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.
|
||||||
|
|
||||||
@@ -4751,6 +4914,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.
|
- 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.
|
- 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.
|
- `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 panel's section titles share the side panels' dock-label colour. 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.
|
- 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`.
|
- 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.
|
- 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.
|
||||||
@@ -4768,7 +4933,7 @@ silently demoting every later lookup for the session.
|
|||||||
- Installations are viewport-bounded OSM map features (`military=airfield|naval_base|range|barracks|base` and `landuse=military`), capped to a 10° non-dateline request and 700 upstream features. The proxy caches five minutes and serves a one-hour stale fallback. Empty, stale, unavailable, and zoom-too-wide states remain visibly distinct.
|
- Installations are viewport-bounded OSM map features (`military=airfield|naval_base|range|barracks|base` and `landuse=military`), capped to a 10° non-dateline request and 700 upstream features. The proxy caches five minutes and serves a one-hour stale fallback. Empty, stale, unavailable, and zoom-too-wide states remain visibly distinct.
|
||||||
- With a selected subject, Contacts hands the installation layer the subject position every refresh. The layer loads a window around the subject (see Installations and map-source guidance), because a follow or Cockpit camera never fires `moveEnd` and often looks at the horizon: a first load from a globe view used to leave the row at "?" for the whole track. The row and the Cockpit scope read `WITHIN 100 KM` instead of `CURRENT VIEWPORT ONLY`, and an empty window reads "none mapped within 100 km; not a complete 250 km survey". `scripts/qa-context-arrivals.mjs` flies synthetic aircraft over Camp Mabry and Fort Cavazos from a wide start, presses CONTACTS, Cockpit and SEARCH NEARBY SITES, and records a clip.
|
- With a selected subject, Contacts hands the installation layer the subject position every refresh. The layer loads a window around the subject (see Installations and map-source guidance), because a follow or Cockpit camera never fires `moveEnd` and often looks at the horizon: a first load from a globe view used to leave the row at "?" for the whole track. The row and the Cockpit scope read `WITHIN 100 KM` instead of `CURRENT VIEWPORT ONLY`, and an empty window reads "none mapped within 100 km; not a complete 250 km survey". `scripts/qa-context-arrivals.mjs` flies synthetic aircraft over Camp Mabry and Fort Cavazos from a wide start, presses CONTACTS, Cockpit and SEARCH NEARBY SITES, and records a clip.
|
||||||
- The selected-only visual is one static, unfilled blue circle. It marks the 250 km proximity context window only; it is not coverage or a radar/weapon envelope. Missing broadcasts and unmapped sites are explicitly not evidence of absence.
|
- The selected-only visual is one static, unfilled blue circle. It marks the 250 km proximity context window only; it is not coverage or a radar/weapon envelope. Missing broadcasts and unmapped sites are explicitly not evidence of absence.
|
||||||
- The right rail's collapsed Display, CCTV, and Context controls use the same compact sizing language as the left rail's collapsed Data Layers and Scenes controls. These compact-state widths do not constrain expanded panel or child-content widths.
|
- The right rail's collapsed Display, CCTV, and Context controls use the same compact sizing language as the left rail's collapsed Data Layers and Authoring controls. These compact-state widths do not constrain expanded panel or child-content widths.
|
||||||
|
|
||||||
### Motion & Symbology Correctness (June 10, 2026)
|
### Motion & Symbology Correctness (June 10, 2026)
|
||||||
|
|
||||||
@@ -4788,7 +4953,9 @@ silently demoting every later lookup for the session.
|
|||||||
wireframe dashed; colours, widths and active/idle emphasis are unchanged. A manual calibration
|
wireframe dashed; colours, widths and active/idle emphasis are unchanged. A manual calibration
|
||||||
(`calSource: 'manual'`) or curated pose (`poseSource: 'curated'`) is never presented as
|
(`calSource: 'manual'`) or curated pose (`poseSource: 'curated'`) is never presented as
|
||||||
estimated, matching the CAL badge. Public camera state carries `headingConfidence` and
|
estimated, matching the CAL badge. Public camera state carries `headingConfidence` and
|
||||||
`headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639).
|
`headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639). The HUD token and the
|
||||||
|
camera panel's meta line wrap the bearing after rounding to whole degrees, so a camera
|
||||||
|
facing 359.5° or more reads `HDG 0°` rather than `HDG 360°`.
|
||||||
- **CCTV identified hardware:** a camera whose source or seed names a hardware `model` that
|
- **CCTV identified hardware:** a camera whose source or seed names a hardware `model` that
|
||||||
resolves in `src/layers/cctv/modelSpecs.js` (vendored CC0 records from the CCTV Camera
|
resolves in `src/layers/cctv/modelSpecs.js` (vendored CC0 records from the CCTV Camera
|
||||||
Database) gets the model's specs appended to the HUD meta line. Only fixed single-lens
|
Database) gets the model's specs appended to the HUD meta line. Only fixed single-lens
|
||||||
@@ -4894,7 +5061,7 @@ silently demoting every later lookup for the session.
|
|||||||
protected-card path. Disable tears the tier down completely.
|
protected-card path. Disable tears the tier down completely.
|
||||||
Coverage/viewshed semantics unchanged.
|
Coverage/viewshed semantics unchanged.
|
||||||
- **Satellites**: orbit rings rotate about Earth's Z by ΔGMST every ~1s (exact inertial→ECEF compensation; no SGP4 re-runs); the tracked satellite propagates per frame with one shared epoch for dot/label/camera. Verified: ISS holds <1km perpendicular to its ring while tracked. Pass prediction (`src/data/satellitePass.js`; `getNextSatellitePass`, `getNextIssPass`) scans at 20s, bisects rise/set to ~0.2s, fits the peak with a parabola, and marks a pass visible when the satellite is outside a cylindrical Earth shadow while the observer's Sun is at or below −6°. `next_iss_pass` preserves its geometric-pass ordering and appends visibility metadata. General `next_satellite_pass` accepts a loaded NORAD ID or unambiguous name, searches 24 hours and optionally requires estimated visibility. These estimates do not account for weather or satellite brightness.
|
- **Satellites**: orbit rings rotate about Earth's Z by ΔGMST every ~1s (exact inertial→ECEF compensation; no SGP4 re-runs); the tracked satellite propagates per frame with one shared epoch for dot/label/camera. Verified: ISS holds <1km perpendicular to its ring while tracked. Pass prediction (`src/data/satellitePass.js`; `getNextSatellitePass`, `getNextIssPass`) scans at 20s, bisects rise/set to ~0.2s, fits the peak with a parabola, and marks a pass visible when the satellite is outside a cylindrical Earth shadow while the observer's Sun is at or below −6°. `next_iss_pass` preserves its geometric-pass ordering and appends visibility metadata. General `next_satellite_pass` accepts a loaded NORAD ID or unambiguous name, searches 24 hours and optionally requires estimated visibility. These estimates do not account for weather or satellite brightness.
|
||||||
- **Space Missions (30d)**: recent launches render as bounded shared-host, horizon-occluded mission markers using Launch Library 2 v2.3 detailed records. Enabling the layer selects the unified right-side Context panel's Space Missions mode and enables the required satellite layer. Before applying its temporary dense/hidden Satellite mode, Space Missions snapshots the complete standalone Satellite parameter set and exact enabled-layer set. Disabling Space Missions from either Context or the left Data Layers rail restores those parameters and the exact prior enabled state, so a Satellite layer that was already on stays on while a mode-owned dependency returns off; enabling Satellites by itself remains independent. Selecting a mission isolates its launch-to-orbit transfer and dashed satellite orbit, fills that same panel with navigation/details, and animates a small phase-colored marker along the exact displayed Cartesian samples. Marker hit testing drill-picks through photorealistic tiles so the depth-test-free tactical dot remains reliably selectable; its text is non-interactive shared-host presentation. The selected pad is the camera's zoom pivot: the overview remains centered on its launch site, wheel zoom approaches that site instead of drifting elsewhere, and camera pitch progressively changes from global nadir to an oblique close 3D view. Its protected shared-host callout remains visible and expands to include both mission and launch-site names. `FOCUS` flies directly to a 12 km oblique frame around the selected launch site and retains the same anchored zoom/orbit behavior. `REPLAY ASCENT` resets the selected marker at the pad, frames it from an oblique third-person angle, and follows it through the mission-specific compressed ascent directly into one orbital lap at the default `1×` rate. A live `0.25×`–`4×` slider changes ascent and orbit playback speed; adjusting it mid-replay preserves the current path position and historical mission timestamp. Re-entry/recovery cannot be inserted into replay. At orbit insertion the camera smoothly pulls back over the first fifth of the orbital replay and pitches to a globe-scale nadir view while continuing to target the moving replay point. Replay Cancel, mission navigation, deselection, layer disable, and data refresh all release camera ownership. Reconstructed paths are one continuous 128-sample geodetic curve: horizontal departure begins near zero while altitude rises quickly, then the climb progressively bends toward insertion without the former hard 120 km corner. Unmatched orbit fallbacks are smooth planar inclined rings rather than longitude/latitude ground-track curves. The panel lists disclosed payload names, types, operators/manufacturers, mass, multiplicity, and destination when supplied; an empty LL2 payload collection is shown as `CLASSIFIED / MULTI-PAYLOAD`. Launcher, spacecraft, and recoverable payload stages appear in a compact stage table with serial/flight/reuse details, recovery outcome/type, destination, and final coordinates when those records exist; an empty recovery collection omits the section. Stage recoveries with confirmed coordinates use those coordinates; return-to-launch-site records use the pad; downrange-only records receive an explicitly labeled estimated endpoint along the ascent azimuth. Available endpoints render as static 2 px dashed descent/recovery paths with a fixed final-position dot and an estimated atmospheric-interface segment when applicable. The ascent is geodetically densified above the ellipsoid toward the orbit's nearest insertion point, then rendered with `ArcType.NONE`, so it neither cuts through Earth nor separates from the marker. Because LL2 does not normally supply continuous ascent telemetry, pad-to-insertion paths without upstream trajectory samples are labeled `RECONSTRUCTED ESTIMATE` / `ASCENT ESTIMATE`; only supplied trajectory samples receive the replay wording. Selected-orbit framing fits the complete ring, rear-side linework uses normal scene depth occlusion, and current distance, speed, and callout data come only from a launch-year-validated satellite match. Speed is the magnitude of the SGP4 inertial velocity vector at the same propagation epoch as position, displayed in km/s with km/h available as hover detail. Unavailable operator, site, launch-time, orbit, current-altitude, and speed values omit their detail rows instead of reserving panel space with placeholders. Newly launched payloads absent from the core operational groups use CelesTrak's cached active TLE feed as a lookup-only fallback; weak constellation-name matches are rejected. The replay callout maps compressed animation progress onto Launch Library's mission-relative timeline, showing the historical UTC date/time at the marker's current path position; unavailable timelines remain explicit. Matched live satellite positions propagate at one-second cadence and use a distinct green dot/callout with the current UTC date/time.
|
- **Space Missions (30d)**: recent launches render as bounded shared-host, horizon-occluded mission markers using Launch Library 2 v2.3 detailed records. Enabling the layer selects the unified right-side Context panel's Space Missions mode and enables the required satellite layer. Before applying its temporary dense/hidden Satellite mode, Space Missions snapshots the complete standalone Satellite parameter set and exact enabled-layer set. Disabling Space Missions from either Context or the left Data Layers rail restores those parameters and the exact prior enabled state, so a Satellite layer that was already on stays on while a mode-owned dependency returns off; enabling Satellites by itself remains independent. Selecting a mission isolates its launch-to-orbit transfer and dashed satellite orbit, fills that same panel with navigation/details, and animates a small phase-colored marker along the exact displayed Cartesian samples. Marker hit testing drill-picks through photorealistic tiles so the depth-test-free tactical dot remains reliably selectable; its text is non-interactive shared-host presentation. The selected pad is the camera's zoom pivot: the overview remains centered on its launch site, wheel zoom approaches that site instead of drifting elsewhere, and camera pitch progressively changes from global nadir to an oblique close 3D view. Its protected shared-host callout remains visible and expands to include both mission and launch-site names. `FOCUS` flies directly to a 12 km oblique frame around the selected launch site and retains the same anchored zoom/orbit behavior. `REPLAY ASCENT` resets the selected marker at the pad, frames it from an oblique third-person angle, and follows it through the mission-specific compressed ascent directly into one orbital lap at the default `1×` rate. A live `0.25×`–`4×` slider changes ascent and orbit playback speed; adjusting it mid-replay preserves the current path position and historical mission timestamp. Re-entry/recovery cannot be inserted into replay. At orbit insertion the camera smoothly pulls back over the first fifth of the orbital replay and pitches to a globe-scale nadir view while continuing to target the moving replay point. Replay Cancel, mission navigation, deselection, layer disable, and data refresh all release camera ownership. Reconstructed paths are one continuous 128-sample geodetic curve: horizontal departure begins near zero while altitude rises quickly, then the climb progressively bends toward insertion without the former hard 120 km corner. Unmatched orbit fallbacks are smooth planar inclined rings rather than longitude/latitude ground-track curves. The panel lists disclosed payload names, types, operators/manufacturers, mass, multiplicity, and destination when supplied; an empty LL2 payload collection is shown as `CLASSIFIED / MULTI-PAYLOAD`. Launcher, spacecraft, and recoverable payload stages appear in a compact stage table with serial/flight/reuse details, recovery outcome/type, destination, and final coordinates when those records exist; an empty recovery collection omits the section. Stage recoveries with confirmed coordinates use those coordinates; return-to-launch-site records use the pad; downrange-only records receive an explicitly labeled estimated endpoint along the ascent azimuth. Available endpoints render as static 2 px dashed descent/recovery paths with a fixed final-position dot and an estimated atmospheric-interface segment when applicable. The ascent is geodetically densified above the ellipsoid toward the orbit's nearest insertion point, then rendered with `ArcType.NONE`, so it neither cuts through Earth nor separates from the marker. Because LL2 does not normally supply continuous ascent telemetry, pad-to-insertion paths without upstream trajectory samples are labeled `RECONSTRUCTED ESTIMATE` / `ASCENT ESTIMATE`; only supplied trajectory samples receive the replay wording. Selected-orbit framing fits the complete ring, rear-side linework uses normal scene depth occlusion, and current distance, speed, and callout data come only from a launch-year-validated satellite match. Both the loaded-catalog and active-TLE lookups read the launch year from the TLE international designator through one parser (`src/sources/tle.js`); a blank or malformed designator is an unknown year, which skips the year check rather than rejecting the match. Speed is the magnitude of the SGP4 inertial velocity vector at the same propagation epoch as position, displayed in km/s with km/h available as hover detail. Unavailable operator, site, launch-time, orbit, current-altitude, and speed values omit their detail rows instead of reserving panel space with placeholders. Newly launched payloads absent from the core operational groups use CelesTrak's cached active TLE feed as a lookup-only fallback; weak constellation-name matches are rejected. The replay callout maps compressed animation progress onto Launch Library's mission-relative timeline,Line truncated
|
||||||
When no mission is selected, the Context panel presents a scrollable newest-first roster of every launch in the rolling window, including the smaller 5 px operator-colored marker, provider, and launch date. Hovering or keyboard-focusing a row shows four compact cyan corner brackets on both that roster row and its corresponding globe dot, rotates the globe at the current zoom to center it, and gives its label declutter priority without selecting it; the globe label remains unbracketed. Selecting a roster row invokes the same mission isolation and full-orbit framing as clicking its globe marker. The replay vehicle is one screen-space SVG/CSS HUD overlay rather than separate Cesium billboard, label, and reticle graphics. It is hidden during ordinary Focus and manual close views, where the standard selected launch-site label remains visible, and exists only while ascent replay is active. Its fixed pixel scale is shared by ascent and insertion, so camera range never resizes the rocket on screen before the phase boundary. Generic Launch Library pad names are reduced to their identifying suffix, and replay timestamps use a cyan state title over unprefixed white UTC date/time values. `REPLAY ASCENT` holds the unframed cyan/white rocket at the pad for a real-time `T−10` countdown, transitions through `LIFTOFF`, and attaches six tapered cyan/white ellipse waves directly below it from liftoff through insertion to convey thrust without adding scene geometry. While replay is active, the single start button is replaced by compact Play, Pause, and Cancel icon controls. Pause freezes countdown or mission time, vehicle/stage positions, camera target, labels, and thrust-wave animation; Play resumes from that exact frame, and Cancel releases replay camera ownership. The rocket and thrust group rotates from the path's live screen-space tangent, so its nose follows the visible ascent curve while the adjacent text remains upright. The camera begins as a close oblique launch chase, then smoothly widens between roughly 120 and 420 km vehicle altitude into a higher oblique context view that keeps the moving rocket targeted while exposing the ascent bend and orbit connection. At insertion the rocket/thrust glyph is replaced by the fixed-size cyan orbit dot, which the camera follows through the existing globe-scale orbit pullback. The chase camera limits per-frame yaw changes across heading wraps so it cannot abruptly cross in front of the vehicle and make ascent read in reverse; the replay-speed slider affects mission playback but not countdown duration.
|
When no mission is selected, the Context panel presents a scrollable newest-first roster of every launch in the rolling window, including the smaller 5 px operator-colored marker, provider, and launch date. Hovering or keyboard-focusing a row shows four compact cyan corner brackets on both that roster row and its corresponding globe dot, rotates the globe at the current zoom to center it, and gives its label declutter priority without selecting it; the globe label remains unbracketed. Selecting a roster row invokes the same mission isolation and full-orbit framing as clicking its globe marker. The replay vehicle is one screen-space SVG/CSS HUD overlay rather than separate Cesium billboard, label, and reticle graphics. It is hidden during ordinary Focus and manual close views, where the standard selected launch-site label remains visible, and exists only while ascent replay is active. Its fixed pixel scale is shared by ascent and insertion, so camera range never resizes the rocket on screen before the phase boundary. Generic Launch Library pad names are reduced to their identifying suffix, and replay timestamps use a cyan state title over unprefixed white UTC date/time values. `REPLAY ASCENT` holds the unframed cyan/white rocket at the pad for a real-time `T−10` countdown, transitions through `LIFTOFF`, and attaches six tapered cyan/white ellipse waves directly below it from liftoff through insertion to convey thrust without adding scene geometry. While replay is active, the single start button is replaced by compact Play, Pause, and Cancel icon controls. Pause freezes countdown or mission time, vehicle/stage positions, camera target, labels, and thrust-wave animation; Play resumes from that exact frame, and Cancel releases replay camera ownership. The rocket and thrust group rotates from the path's live screen-space tangent, so its nose follows the visible ascent curve while the adjacent text remains upright. The camera begins as a close oblique launch chase, then smoothly widens between roughly 120 and 420 km vehicle altitude into a higher oblique context view that keeps the moving rocket targeted while exposing the ascent bend and orbit connection. At insertion the rocket/thrust glyph is replaced by the fixed-size cyan orbit dot, which the camera follows through the existing globe-scale orbit pullback. The chase camera limits per-frame yaw changes across heading wraps so it cannot abruptly cross in front of the vehicle and make ascent read in reverse; the replay-speed slider affects mission playback but not countdown duration.
|
||||||
Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources.
|
Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources.
|
||||||
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
|
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
|
||||||
@@ -5117,14 +5284,16 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
cannot race the nearest-aircraft query ahead of layer enablement. A healthy fallback feed is
|
cannot race the nearest-aircraft query ahead of layer enablement. A healthy fallback feed is
|
||||||
queried normally and its source is returned with the selection; fallback with
|
queried normally and its source is returned with the selection; fallback with
|
||||||
no airborne records remains an honest no-data result, not an enable failure.
|
no airborne records remains an honest no-data result, not an enable failure.
|
||||||
|
The spoken reply and the card leave out an altitude the feed did not report
|
||||||
|
rather than reading it as 0 feet.
|
||||||
- Voice treats the parent Context panel and Contacts as separate intents. An
|
- Voice treats the parent Context panel and Contacts as separate intents. An
|
||||||
explicit request to open Context expands only `global-context-panel`; it does
|
explicit request to open Context expands only `global-context-panel`; it does
|
||||||
not choose a mode. An explicit request to open Contacts expands that parent
|
not choose a mode. An explicit request to open Contacts expands that parent
|
||||||
first, activates the Contacts sub-view, and returns the settled 250 km window.
|
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
|
Its `aircraft` count is the exact civilian-plus-military total when both feeds
|
||||||
can answer, or `unknown` when either component is unavailable.
|
can answer, or `unknown` when either component is unavailable.
|
||||||
- Cockpit's top vision switch cycles all seven map looks: Normal, CRT, NVG,
|
- Cockpit's top vision switch cycles all six map looks: Normal, CRT, NVG,
|
||||||
FLIR, Anime, Noir, and Snow. Entry selects the matching map style without adding
|
FLIR, Anime, and Noir. Entry selects the matching map style without adding
|
||||||
a duplicate carousel item. There is no empty `NONE` entry.
|
a duplicate carousel item. There is no empty `NONE` entry.
|
||||||
|
|
||||||
### Live AIS Vessels (June 2026)
|
### Live AIS Vessels (June 2026)
|
||||||
@@ -5164,7 +5333,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
|
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
|
||||||
- **Public control facade** on StyleManager (`setHudVisible/setHudLayout/setDetection/setMapStack/setBloom/setSharpen/setOrbit/setCleanView/getControlState` plus `runImmediateNavigation`): every setter syncs DOM sliders + share links + scene snapshots and returns `{ok, ...state}` — voice confirms only what actually happened. Validated `move_camera`, `fly_route`, `frame_overhead`, strongest-fire focus, and entity tracking use the shared navigation transaction, which refuses Cockpit before mutation, advances authority, releases follow owners, cancels stale work, and only then starts the requested action. All four entity layers expose `findByQuery/getNearby/getAllPositions/trackById|selectById/stopTracking/getTrackedInfo`.
|
- **Public control facade** on StyleManager (`setHudVisible/setHudLayout/setDetection/setMapStack/setBloom/setSharpen/setOrbit/setCleanView/getControlState` plus `runImmediateNavigation`): every setter syncs DOM sliders + share links + scene snapshots and returns `{ok, ...state}` — voice confirms only what actually happened. Validated `move_camera`, `fly_route`, `frame_overhead`, strongest-fire focus, and entity tracking use the shared navigation transaction, which refuses Cockpit before mutation, advances authority, releases follow owners, cancels stale work, and only then starts the requested action. All four entity layers expose `findByQuery/getNearby/getAllPositions/trackById|selectById/stopTracking/getTrackedInfo`.
|
||||||
- **Scene context** (`get_entity_context`): selected entity from the context store, or visible entities ranked by distance to the view target (≤100km altitude); plus basemap context — view-target picking, 7-point viewport sampling, view-scale classification (global/continental/regional/metro/city/local), reverse geocoding (center ≤750km, viewport samples ≤3,000km), Google Places Nearby via `/api/google/nearby-places` (≤25km), known-landmark matching against `CITY_POIS`, coarse country inference fallback. Context assembly is capped at 1.5s with cache fallbacks; caches are deduped in-flight.
|
- **Scene context** (`get_entity_context`): selected entity from the context store, or visible entities ranked by distance to the view target (≤100km altitude); plus basemap context — view-target picking, 7-point viewport sampling, view-scale classification (global/continental/regional/metro/city/local), reverse geocoding (center ≤750km, viewport samples ≤3,000km), Google Places Nearby via `/api/google/nearby-places` (≤25km), known-landmark matching against `CITY_POIS`, coarse country inference fallback. Context assembly is capped at 1.5s with cache fallbacks; caches are deduped in-flight.
|
||||||
- **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame.
|
- **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame. The flights and military context records wrap the track after rounding to whole degrees, so a contact on 359.5° or more reads `0°` rather than `360°`.
|
||||||
- **Context-mode vocabulary is symmetric** (2026-08-21). `set_context_mode` accepts `contacts`; the internal id is `flights`. Every model-readable field (`mode`, `entering`, `priorMode`, nested `context`/`contextRollback`, and the transition diagnostic text) is reported in the accepted vocabulary, with the internal id preserved as `<field>Internal`; an absent secondary mode stays `null` rather than claiming to be `off`. Mapping lives in `src/contextModePolicy.js` so UI text and voice payloads cannot drift. Reporting the internal id made the model read `mode:'flights'` as "Contacts is off" and refuse to answer from the `contactsWindow` counts in the same payload.
|
- **Context-mode vocabulary is symmetric** (2026-08-21). `set_context_mode` accepts `contacts`; the internal id is `flights`. Every model-readable field (`mode`, `entering`, `priorMode`, nested `context`/`contextRollback`, and the transition diagnostic text) is reported in the accepted vocabulary, with the internal id preserved as `<field>Internal`; an absent secondary mode stays `null` rather than claiming to be `off`. Mapping lives in `src/contextModePolicy.js` so UI text and voice payloads cannot drift. Reporting the internal id made the model read `mode:'flights'` as "Contacts is off" and refuse to answer from the `contactsWindow` counts in the same payload.
|
||||||
- **Analyst → track handoff carries a key, not just a label** (2026-08-21). `analyst_query` items include `icao24`/`mmsi` alongside the display `id`, and contact lookup uses the shared tiered ranking in `src/data/contactMatch.js`: hex exact → callsign exact → registration exact → callsign prefix → registration prefix → callsign substring → registration substring. The tiers keep an exact callsign ahead of a colliding registration regardless of feed order, and registrations compare separator-insensitively (`G-ABCD`/`GABCD`, `05-8152`/`058152`, `N123AB`/`N-123AB`).
|
- **Analyst → track handoff carries a key, not just a label** (2026-08-21). `analyst_query` items include `icao24`/`mmsi` alongside the display `id`, and contact lookup uses the shared tiered ranking in `src/data/contactMatch.js`: hex exact → callsign exact → registration exact → callsign prefix → registration prefix → callsign substring → registration substring. The tiers keep an exact callsign ahead of a colliding registration regardless of feed order, and registrations compare separator-insensitively (`G-ABCD`/`GABCD`, `05-8152`/`058152`, `N123AB`/`N-123AB`).
|
||||||
- **A typed command supersedes the turn it interrupts** (2026-08-21; hardened 2026-10-17). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items. If the local channel rejects the user item, that command requests no response and adds no deferred answer; earlier accepted deferred input remains queued. Interruption and pointer preparation still happen before the send attempt.
|
- **A typed command supersedes the turn it interrupts** (2026-08-21; hardened 2026-10-17). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items. If the local channel rejects the user item, that command requests no response and adds no deferred answer; earlier accepted deferred input remains queued. Interruption and pointer preparation still happen before the send attempt.
|
||||||
@@ -5267,7 +5436,13 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total
|
|||||||
deadline, with Photon requests capped at six seconds each. Caller/application
|
deadline, with Photon requests capped at six seconds each. Caller/application
|
||||||
cancellation stops retries and late cache writes. Replacing a location search
|
cancellation stops retries and late cache writes. Replacing a location search
|
||||||
cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five
|
cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five
|
||||||
candidates, and an unbiased retry for name mismatches. Invalid or wrapped bounds returned by providers
|
candidates, and an unbiased retry for name mismatches. A "place, region" query
|
||||||
|
prefers a candidate named exactly for the place whose city, county, state,
|
||||||
|
country or country code (or USA/UK) contains every word of the region, and a
|
||||||
|
settlement or administrative area over a POI when both fit. Nearby results that
|
||||||
|
fail that check are retried unbiased. After the region checks of both passes, a
|
||||||
|
nearby place whose whole name is the query (one with a comma in it) comes next,
|
||||||
|
then the unbiased name match. Invalid or wrapped bounds returned by providers
|
||||||
are omitted rather than framing the wrong part of the globe.
|
are omitted rather than framing the wrong part of the globe.
|
||||||
|
|
||||||
A location search that finds nothing, or fails, leaves the field open with the
|
A location search that finds nothing, or fails, leaves the field open with the
|
||||||
@@ -5342,14 +5517,15 @@ request viewbox and continues searching without that geographic preference.
|
|||||||
### Manual Whiteboard Drawing (September 2026)
|
### Manual Whiteboard Drawing (September 2026)
|
||||||
|
|
||||||
- DISPLAY ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (Area, Line, Pin), click the vertices on the real world, type an optional label and pick a colour, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. Typing in the label field keeps Backspace; Enter there also finishes. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone.
|
- DISPLAY ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (Area, Line, Pin), click the vertices on the real world, type an optional label and pick a colour, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. Typing in the label field keeps Backspace; Enter there also finishes. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone.
|
||||||
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its markup is a DISPLAY-rail component template (`src/ui/templates/display-controls.html`), assembled into `index.html` at build time. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `diagnostics`) is the no-mouse test seam.
|
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its markup is in the DISPLAY-rail view (`src/ui/views/DisplayControlsView.vue`), rendered into its `index.html` host before the page is wired. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `diagnostics`) is the no-mouse test seam.
|
||||||
- Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, de-dup, appear in `.list()`, and clear with `clear_annotations` / `.clear()`.
|
- Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, de-dup, appear in `.list()`, and clear with `clear_annotations` / `.clear()`.
|
||||||
- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it.
|
- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it.
|
||||||
- **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen".
|
- **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen".
|
||||||
- **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew.
|
- **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew.
|
||||||
- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`.
|
- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`, and drop the kilometre decimal from 9.95 km. The placed mark's label (shared with spoken routes and arrows) rounds before each unit cut, so a 996-metre line is labelled `1.0 km`, not `1000 m`, and a 9,960-metre one `10 km`, not `10.0 km`.
|
||||||
- **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it.
|
- **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it.
|
||||||
- **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which.
|
- **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which.
|
||||||
|
- Drawn-area measures round before choosing m², ha or km², so 9,999.6 m² displays as `1.0 ha`, not `10000 m²`, and 999,960 m² as `1.00 km²`, not `100.0 ha`.
|
||||||
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection.
|
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection.
|
||||||
- **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing.
|
- **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing.
|
||||||
- **Cesium's own click actions are borrowed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. Both are removed for the session and restored, as the same function objects, when it ends or the tool is destroyed.
|
- **Cesium's own click actions are borrowed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. Both are removed for the session and restored, as the same function objects, when it ends or the tool is destroyed.
|
||||||
@@ -5406,7 +5582,7 @@ pins and share links already supply complete boxes.
|
|||||||
- **Modes, pins and the preview.** The mode is explicit: `image` (one pinned day, slot `a`), `basemap` (that day on the left of a swipe, the basemap on the right: slot `a` splits LEFT and there is no second layer) and `ab` (day `a` left, day `b` right). `setAssignment(slot, key | null)` / `toggleAssignment(slot, key)` pin and unpin; a day is in one slot at most (pinning it in the other slot moves it) and an empty day cannot be pinned. `setMode()` keeps both pins; `b` only drapes in `ab`. While the mode's first empty slot exists the focused day previews in it: a click at once, arrow keys after 250 ms of quiet (`FOCUS_DEBOUNCE_MS`, one timer, last day wins); an unprobed day is followed and previews when its probe says present, an empty or unknown day never drapes and the previous preview stays. A pin change drops the preview until focus moves again, and unpinning takes that layer off at once. `rankLatest` picks START HERE coverage first (a present HLS day whose footprints cover the box, or have none, beats a newer sliver), then newest with all granules ≤ 20% known cloud, then newest; slivers only as `partial`; else the newest non-empty VIIRS day; it is focused and previewed after each search when nothing is pinned. Escape clears the preview (`clearPreview()`), then cancels the box tool, then blurs — with SELECT BOX armed too, through the tool's `onEscape` interceptor wired in `createApplicationTools`. Pins, mode, box and split changes made in the panel are published with `adoptLayerParams`, never while a share link is being applied.
|
- **Modes, pins and the preview.** The mode is explicit: `image` (one pinned day, slot `a`), `basemap` (that day on the left of a swipe, the basemap on the right: slot `a` splits LEFT and there is no second layer) and `ab` (day `a` left, day `b` right). `setAssignment(slot, key | null)` / `toggleAssignment(slot, key)` pin and unpin; a day is in one slot at most (pinning it in the other slot moves it) and an empty day cannot be pinned. `setMode()` keeps both pins; `b` only drapes in `ab`. While the mode's first empty slot exists the focused day previews in it: a click at once, arrow keys after 250 ms of quiet (`FOCUS_DEBOUNCE_MS`, one timer, last day wins); an unprobed day is followed and previews when its probe says present, an empty or unknown day never drapes and the previous preview stays. A pin change drops the preview until focus moves again, and unpinning takes that layer off at once. `rankLatest` picks START HERE coverage first (a present HLS day whose footprints cover the box, or have none, beats a newer sliver), then newest with all granules ≤ 20% known cloud, then newest; slivers only as `partial`; else the newest non-empty VIIRS day; it is focused and previewed after each search when nothing is pinned. Escape clears the preview (`clearPreview()`), then cancels the box tool, then blurs — with SELECT BOX armed too, through the tool's `onEscape` interceptor wired in `createApplicationTools`. Pins, mode, box and split changes made in the panel are published with `adoptLayerParams`, never while a share link is being applied.
|
||||||
- **Rendering.** `rendering.js` owns at most two GIBS layers on the host `src/maps/imageryHost.js` resolves: `viewer.imageryLayers` while the globe shows, the photoreal tileset's `imageryLayers` while it is hidden, or none (the panel says so). Layers are rebuilt on a new host, never re-parented. `MapSourceController.subscribe()` fires after every settled stack switch (silent switches and fallbacks included) and the layer rebinds on it. Tile requests pass a six-slot semaphore of our own (Cesium's global limits are untouched). Under `requestRenderMode` a deferred tile needs a later frame, but a deferral must never request one itself or a stalled tile keeps the scene rendering every frame: a request that finds the semaphore full asks for nothing (the settling request asks for one frame), and a tile Cesium itself deferred goes through one coalesced 250 ms retry. Thumbnails: three fetches at once, sixteen decoded images, least-recently-used where a `get` read is never a use; eviction keeps the probe result. The strip's visible window is probed around the focused card; the pinned and previewed days are probed wherever they sit, at the focused card's priority, so a pin restored or assigned off-screen still drapes.
|
- **Rendering.** `rendering.js` owns at most two GIBS layers on the host `src/maps/imageryHost.js` resolves: `viewer.imageryLayers` while the globe shows, the photoreal tileset's `imageryLayers` while it is hidden, or none (the panel says so). Layers are rebuilt on a new host, never re-parented. `MapSourceController.subscribe()` fires after every settled stack switch (silent switches and fallbacks included) and the layer rebinds on it. Tile requests pass a six-slot semaphore of our own (Cesium's global limits are untouched). Under `requestRenderMode` a deferred tile needs a later frame, but a deferral must never request one itself or a stalled tile keeps the scene rendering every frame: a request that finds the semaphore full asks for nothing (the settling request asks for one frame), and a tile Cesium itself deferred goes through one coalesced 250 ms retry. Thumbnails: three fetches at once, sixteen decoded images, least-recently-used where a `get` read is never a use; eviction keeps the probe result. The strip's visible window is probed around the focused card; the pinned and previewed days are probed wherever they sit, at the focused card's priority, so a pin restored or assigned off-screen still drapes.
|
||||||
- **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape.
|
- **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape.
|
||||||
- **Panel.** `#recent-imagery-panel` is static rail markup in `src/ui/templates/context.html` beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`; opening DETAILS then scrolls its card into view once.
|
- **Panel.** `#recent-imagery-panel` is rail markup in the Context view (`src/ui/views/ContextView.vue`) beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`; opening DETAILS then scrolls its card into view once.
|
||||||
- **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off).
|
- **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off).
|
||||||
- Regression surface: the colocated `*.test.mjs` beside each module (shared doubles in `src/layers/recentImagery/testDoubles.mjs`; `src/ui/recentImagery.test.mjs` includes a DOM-structure guard: every control keeps its place in the tree, nothing is hidden, and every fixed-height block keeps the class the stylesheet fixes its height by; it has no layout, so it cannot see movement), `src/ui/layerPanel.test.mjs` (mount), `src/maps/controller.test.mjs` (`subscribe`), `src/data/layerState.test.mjs` (codec), `src/sharelink.celestial.test.mjs` (panel token), and the live gate `scripts/qa-recent-imagery.mjs`, which also measures every control's rect, relative to the panel, across the transitions within 1 px; that measurement is the real layout-shift check. `QA_RECENT_IMAGERY_SLOW_RENDER=1` gives loaded software renderers a 4 s zoom deadline and an 8-sample stability tail; the default stays 2 s and 12 samples.
|
- Regression surface: the colocated `*.test.mjs` beside each module (shared doubles in `src/layers/recentImagery/testDoubles.mjs`; `src/ui/recentImagery.test.mjs` includes a DOM-structure guard: every control keeps its place in the tree, nothing is hidden, and every fixed-height block keeps the class the stylesheet fixes its height by; it has no layout, so it cannot see movement), `src/ui/layerPanel.test.mjs` (mount), `src/maps/controller.test.mjs` (`subscribe`), `src/data/layerState.test.mjs` (codec), `src/sharelink.celestial.test.mjs` (panel token), and the live gate `scripts/qa-recent-imagery.mjs`, which also measures every control's rect, relative to the panel, across the transitions within 1 px; that measurement is the real layout-shift check. `QA_RECENT_IMAGERY_SLOW_RENDER=1` gives loaded software renderers a 4 s zoom deadline and an 8-sample stability tail; the default stays 2 s and 12 samples.
|
||||||
|
|
||||||
@@ -5445,10 +5621,10 @@ pins and share links already supply complete boxes.
|
|||||||
|
|
||||||
### Split-flap status chips (August 2026)
|
### Split-flap status chips (August 2026)
|
||||||
|
|
||||||
- The three status chips flip their LABELS over character by character when the text changes, like a departure board: `#global-loading-label` ("LOADING LIVE DATA" → "LOAD COMPLETE"), `#traffic-sync-label`, and `#cctv-sync-label` ("loading frames" → "camera grid ready"). All three route through `setSplitFlapText()` in `src/splitFlap.js`; there is no other writer of those three elements. The progress counters (`#*-sync-progress`, `#global-loading-detail`) are deliberately left as plain `textContent` — they tick several times a second, and flapping them reads as a slot machine.
|
- The three status chips flip their LABELS over character by character when the text changes, like a departure board: `#global-loading-label` ("LOADING LIVE DATA" → "LOAD COMPLETE"), `#traffic-sync-label`, and `#cctv-sync-label` ("loading frames" → "camera grid ready"). ShellFeedback and CctvControls publish them to the `gev-status` store, and the scene chrome renders each label with `SplitFlapText.vue`, which plays the cascade `src/splitFlap.js` plans; there is no other writer of those three labels. When an owner is destroyed it publishes the label `stopped`, which rests it at once. The progress counters (`#*-sync-progress`, `#global-loading-detail`) are deliberately left as plain `textContent` — they tick several times a second, and flapping them reads as a slot machine.
|
||||||
- `#global-loading-label` doubles as the universal top-center status banner, so anything routed through `_showGlobalStatusNotice()` flaps as well — in particular the share-link restore notices, of which "Shared military flight could not be restored — feed unavailable" is the longest at 63 characters. That needs no special case: `planSplitFlap()` compresses the stagger to hold the 620 ms budget (26 ms → 6.9 ms per column at that length), every column is reserved for the whole cascade, and `element.textContent` is the complete notice at every instant, so the `aria-live` region announces the whole sentence rather than a fragment. A notice deferred minutes past boot is equally safe: `ensureHost()` re-validates the shell on every call, and the long-lived `Text` node is never replaced.
|
- `#global-loading-label` doubles as the universal top-center status banner, so anything routed through `_showGlobalStatusNotice()` flaps as well — in particular the share-link restore notices, of which "Shared military flight could not be restored — feed unavailable" is the longest at 63 characters. That needs no special case: `planSplitFlap()` compresses the stagger to hold the 620 ms budget (26 ms → 6.9 ms per column at that length), every column is reserved for the whole cascade, and `element.textContent` is the complete notice at every instant, so the `aria-live` region announces the whole sentence rather than a fragment. A notice deferred minutes past boot is equally safe: the long-lived `Text` node is never replaced.
|
||||||
- **DOM text is the truth, and its node NEVER moves — do not "fix" this.** The first call upgrades a chip label into a permanent shell (`ensureHost`): a `.gev-flap-text` span holding one long-lived `Text` node, plus an `aria-hidden` `.gev-flap-cells` sibling. After that the ONLY text operation for the life of the chip is `node.data = next`. Nothing is reparented, so the label is never transiently empty and the `aria-live` region never sees a removal/reinsertion pair it could announce twice. `element.textContent` is the settled string at every instant, because the cells carry no text at all: both glyphs are CSS generated content (`::before` from `data-flap-prev` = outgoing, `::after` from `data-flap-next` = incoming), which never reaches `textContent`. This keeps QA pins honest and lets `_updateTrafficSyncChip`'s own `textContent !==` guard keep working. The shell is built on a tick where the text is NOT changing, so no real label change ever carries a structural mutation.
|
- **DOM text is the truth, and its node NEVER moves — do not "fix" this.** The label is a `.gev-flap-text` span holding one long-lived `Text` node, plus an `aria-hidden` `.gev-flap-cells` sibling; the component's `v-flap-text` directive writes it, because Vue would patch a lone `{{ text }}` child by setting `textContent` and swap the node. The ONLY text operation for the life of the chip is `node.data = next`. Nothing is reparented, so the label is never transiently empty and the `aria-live` region never sees a removal/reinsertion pair it could announce twice. `element.textContent` is the settled string at every instant, because the cells carry no text at all: both glyphs are CSS generated content (`::before` from `data-flap-prev` = outgoing, `::after` from `data-flap-next` = incoming), which never reaches `textContent`. This keeps QA pins honest. Whether the label is on screen is read once the change has rendered, after its chip shows or hides with it; the cells go in before the browser paints.
|
||||||
- **No animation loop, and exactly ONE `setTimeout` per change.** CSS `animation`/`transition` only, triggered once per text change and staggered through a per-cell `--gev-flap-delay`. The single timer is the settle that strips the cells; the width ease ends on a `transitionend`/`transitioncancel` listener, never a second timer. Idle cost is zero, there is no periodic work, and nothing requests a Cesium render or takes a render-governor hold. `setSplitFlapText` is a no-op on unchanged text, which is required — the chips are repainted by a 60 ms and a 500 ms ticker.
|
- **No animation loop, and exactly ONE `setTimeout` per change.** CSS `animation`/`transition` only, triggered once per text change and staggered through a per-cell `--gev-flap-delay`. The single timer is the settle that strips the cells; the width ease ends on a `transitionend`/`transitioncancel` listener, never a second timer. Idle cost is zero, there is no periodic work, and nothing requests a Cesium render or takes a render-governor hold. An unchanged label is not republished, which is required — the chips are recomputed by a 60 ms and a 500 ms ticker.
|
||||||
- **Only what was visible flaps away.** An interrupted cascade (A→B cut short by C) derives each column's outgoing glyph from `visibleGlyphs()` — what that column is actually SHOWING at that instant, which for a column whose stagger has not elapsed is still A, not the pending B. `FLAP_TURN_RATIO` must track the `gev-flap-out`/`gev-flap-in` keyframe crossover in style.css.
|
- **Only what was visible flaps away.** An interrupted cascade (A→B cut short by C) derives each column's outgoing glyph from `visibleGlyphs()` — what that column is actually SHOWING at that instant, which for a column whose stagger has not elapsed is still A, not the pending B. `FLAP_TURN_RATIO` must track the `gev-flap-out`/`gev-flap-in` keyframe crossover in style.css.
|
||||||
- **Columns never renumber mid-cascade — do not "optimise" this away.** For the whole cascade the board keeps one column per index of the LONGER string, each holding its own width; a column the new string does not reach flaps to a BLANK in place (`data-flap-next=" "`) rather than collapsing. Collapsing stacks the absolutely-positioned outgoing glyphs on one x AND lets a later glyph slide into an earlier column, which makes `visibleGlyphs()` lie and the interrupt rule flap the wrong glyph away. Pinned by "a cleared column holds its place instead of letting later glyphs slide left".
|
- **Columns never renumber mid-cascade — do not "optimise" this away.** For the whole cascade the board keeps one column per index of the LONGER string, each holding its own width; a column the new string does not reach flaps to a BLANK in place (`data-flap-next=" "`) rather than collapsing. Collapsing stacks the absolutely-positioned outgoing glyphs on one x AND lets a later glyph slide into an earlier column, which makes `visibleGlyphs()` lie and the interrupt rule flap the wrong glyph away. Pinned by "a cleared column holds its place instead of letting later glyphs slide left".
|
||||||
- Length changes are eased, never snapped, and the ease is placed so it never fights the flaps (`.gev-flap-sizing`): a GROWING label reserves its columns as the cells go in and eases at the START; a SHRINKING one holds full width for the whole cascade and eases at SETTLEMENT.
|
- Length changes are eased, never snapped, and the ease is placed so it never fights the flaps (`.gev-flap-sizing`): a GROWING label reserves its columns as the cells go in and eases at the START; a SHRINKING one holds full width for the whole cascade and eases at SETTLEMENT.
|
||||||
@@ -5534,8 +5710,8 @@ apply to `public/` assets or built-preview serving.
|
|||||||
- GEV MIC control is a glass capsule (var(--glass-bg), blur(24px) saturate(1.4), 999px radius; panel radius in error state).
|
- GEV MIC control is a glass capsule (var(--glass-bg), blur(24px) saturate(1.4), 999px radius; panel radius in error state).
|
||||||
- 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 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).
|
- 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.
|
- Display and Context use matching 330 px expanded widths and matching compact tab dimensions. The parameter panel is part of Display's expanded content. One right-rail panel is open at a time in every HUD layout and collapsed launchers are hidden; the panel rail beside the stack switches between them (see Panel rails). The most recently opened right-rail panel owns the lane even when it appears later in DOM order; passive restoration and automatic disclosure do not replace that explicit owner. When the open panel exceeds the measured corridor, it scrolls 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 Authoring 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.
|
- `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.
|
- 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.
|
- 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.
|
||||||
@@ -5600,12 +5776,13 @@ apply to `public/` assets or built-preview serving.
|
|||||||
be enabled, and a civilian or military aircraft must be tracked. The visible
|
be enabled, and a civilian or military aircraft must be tracked. The visible
|
||||||
Cockpit actions and the `C` shortcut use the same gate, so ordinary standalone
|
Cockpit actions and the `C` shortcut use the same gate, so ordinary standalone
|
||||||
flight-layer selection cannot enter a context-dependent cockpit.
|
flight-layer selection cannot enter a context-dependent cockpit.
|
||||||
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`). Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Scenes and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session.
|
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`). Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session.
|
||||||
- **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 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 Authoring 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 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 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 wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`.
|
||||||
|
- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`.
|
||||||
- **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 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.
|
- **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.
|
||||||
- **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material.
|
- **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material.
|
||||||
@@ -5758,7 +5935,11 @@ easier to meet (detection is now on more often), but does not create it.
|
|||||||
coverage number. `stats.loading` covers outstanding flow work as well as the
|
coverage number. `stats.loading` covers outstanding flow work as well as the
|
||||||
road fetch, so a flow failure landing after the first paint still ends the
|
road fetch, so a flow failure landing after the first paint still ends the
|
||||||
shared loading batch as LOAD FAILED. Harnesses must gate on `!stats.error`,
|
shared loading batch as LOAD FAILED. Harnesses must gate on `!stats.error`,
|
||||||
never on `mode === 'live'` alone.
|
never on `mode === 'live'` alone. When the drawn flow came from the proxy's
|
||||||
|
cached STALE-BUDGET / STALE-ERROR tiles, `getStats()` reports `stale: true`
|
||||||
|
and `oldestTileAt`, the row reads STALE with the oldest tile's age, and the
|
||||||
|
sync chip reads
|
||||||
|
`STALE · Roads: <source> · Flow: cached TomTom tiles` instead of LIVE.
|
||||||
- Traffic runs in `sim` mode (white dots, hardcoded speeds) unless `TOMTOM_API_KEY`
|
- Traffic runs in `sim` mode (white dots, hardcoded speeds) unless `TOMTOM_API_KEY`
|
||||||
is configured (env or Keychain `tomtom-api`/`api-key`), which enables `live` mode:
|
is configured (env or Keychain `tomtom-api`/`api-key`), which enables `live` mode:
|
||||||
TomTom flow vector tiles via the budget-governed `/api/tomtom` proxy
|
TomTom flow vector tiles via the budget-governed `/api/tomtom` proxy
|
||||||
@@ -5810,9 +5991,11 @@ easier to meet (detection is now on more often), but does not create it.
|
|||||||
OSM choice), so Hybrid fill is never replayed as OSM roads. Failed flow
|
OSM choice), so Hybrid fill is never replayed as OSM roads. Failed flow
|
||||||
refreshes clear old matches and report simulation. `flowCoveragePct` is the
|
refreshes clear old matches and report simulation. `flowCoveragePct` is the
|
||||||
percentage of shown dots on roads with flow, including all unmatched dots in
|
percentage of shown dots on roads with flow, including all unmatched dots in
|
||||||
the denominator and excluding dots hidden by closures. The OSM keyed status
|
the denominator and excluding dots hidden by closures. It reads 1–99 whenever
|
||||||
reads `LIVE · Roads: OpenStreetMap · Flow: TomTom · N% cov`. Road fetch
|
both matched and simulated dots are shown, so rounding never turns a few
|
||||||
coverage follows a 5×5 ellipsoid sample of the central screen square.
|
matches into `(no matches)` or a few simulated dots into full coverage. The
|
||||||
|
OSM keyed status reads `LIVE · Roads: OpenStreetMap · Flow: TomTom · N% cov`.
|
||||||
|
Road fetch coverage follows a 5×5 ellipsoid sample of the central screen square.
|
||||||
Horizon rays are capped at 10 km from nadir (reduced if necessary to fit
|
Horizon rays are capped at 10 km from nadir (reduced if necessary to fit
|
||||||
16 z12 tiles); polygon-intersecting tiles load nearest first. Up to 16 z14
|
16 z12 tiles); polygon-intersecting tiles load nearest first. Up to 16 z14
|
||||||
tiles provide near detail, with z12 roads beyond them. Cached tiles serve
|
tiles provide near detail, with z12 roads beyond them. Cached tiles serve
|
||||||
@@ -5963,7 +6146,7 @@ After the initial broadside launch profile, the ascent chase camera stays in a r
|
|||||||
During orbit replay, the camera continues following the selected vehicle but eases its look-at target down toward the vehicle's sub-satellite globe anchor. The range expands when necessary for high-altitude missions, keeping both Earth and the tracked label visible through the full revolution. The active replay clock clamps at the final orbital sample rather than wrapping to ascent progress zero; replay completion therefore leaves the final globe/orbit framing in place and does not return to the launch site.
|
During orbit replay, the camera continues following the selected vehicle but eases its look-at target down toward the vehicle's sub-satellite globe anchor. The range expands when necessary for high-altitude missions, keeping both Earth and the tracked label visible through the full revolution. The active replay clock clamps at the final orbital sample rather than wrapping to ascent progress zero; replay completion therefore leaves the final globe/orbit framing in place and does not return to the launch site.
|
||||||
|
|
||||||
Reconstructed mission orbits use a small downrange launch-to-insertion arc, so their estimated ground track is not artificially drawn directly over the launch pad in top-down views. The ascent remains connected to the ring at its selected insertion point.
|
Reconstructed mission orbits use a small downrange launch-to-insertion arc, so their estimated ground track is not artificially drawn directly over the launch pad in top-down views. The ascent remains connected to the ring at its selected insertion point.
|
||||||
Collapsed right-rail controls use the same 176 px width as collapsed left-rail controls, while expanded right-side detail panels retain their independent widths. DISPLAY starts expanded only on first run and then respects persistence; DISPLAY may remain open beside CCTV or Context, while CCTV and Context remain mutually exclusive without persisting forced collapses. Selecting a dedicated Context mode opens its right-side surface and clears unrelated layers after first snapshotting their exact state. Final exit restores the original enabled set and changed parameters. Cockpit View hides the right-side CCTV control because CCTV is not part of the cockpit rail. Airborne cockpit altitude uses the tracked aircraft's reported aviation MSL altitude, never the potentially negative Cesium terrain/ellipsoid render height; confirmed grounded contacts display `0 ft` without rewriting that source field. A cold photoreal floor shows `ACQUIRING SURFACE` for at most five seconds, then uses the source target-height fallback instead of freezing the camera indefinitely.
|
Collapsed right-rail controls use the same 176 px width as collapsed left-rail controls, while expanded right-side detail panels retain their independent widths. DISPLAY starts expanded only on first run and then respects persistence; one right-side panel is open at a time, without persisting the collapses that rule forces. Selecting a dedicated Context mode opens its right-side surface and clears unrelated layers after first snapshotting their exact state. Final exit restores the original enabled set and changed parameters. Cockpit View hides the right-side CCTV control because CCTV is not part of the cockpit rail. Airborne cockpit altitude uses the tracked aircraft's reported aviation MSL altitude, never the potentially negative Cesium terrain/ellipsoid render height; confirmed grounded contacts display `0 ft` without rewriting that source field. A cold photoreal floor shows `ACQUIRING SURFACE` for at most five seconds, then uses the source target-height fallback instead of freezing the camera indefinitely.
|
||||||
Replay transport uses one Play/Pause toggle plus Cancel. During ascent only the active thrust ring is visible; stage-recovery handoff uses a pulsing dot.
|
Replay transport uses one Play/Pause toggle plus Cancel. During ascent only the active thrust ring is visible; stage-recovery handoff uses a pulsing dot.
|
||||||
|
|
||||||
## Tooling Snapshot
|
## Tooling Snapshot
|
||||||
@@ -6061,6 +6244,11 @@ reception does not interrupt internet radio.
|
|||||||
Changes apply to the open receiver without reconnecting.
|
Changes apply to the open receiver without reconnecting.
|
||||||
- **Receiver stats (ADS-B).** CRC-valid messages per second, aircraft heard,
|
- **Receiver stats (ADS-B).** CRC-valid messages per second, aircraft heard,
|
||||||
aircraft with a fresh position and the IQ level.
|
aircraft with a fresh position and the IQ level.
|
||||||
|
- **Error text.** A receiver error sets two fields (`errorState()` in
|
||||||
|
`src/sdr/controller.js`): `message` is the UI language for the Radio card,
|
||||||
|
`errorText` is the English text the Local ADS-B layer reports as its stats
|
||||||
|
`error` (`localAdsbStatus()`, falling back to `message` when a receiver sets
|
||||||
|
none); the Layers panel translates it where it shows it.
|
||||||
- **Devices.** An already-authorized receiver opens without the WebUSB
|
- **Devices.** An already-authorized receiver opens without the WebUSB
|
||||||
picker. ADS-B prefers a device whose product name matches ADS-B or 1090 (the
|
picker. ADS-B prefers a device whose product name matches ADS-B or 1090 (the
|
||||||
1090 MHz channel of a dual-channel board); FM avoids ADS-B/UAT channels.
|
1090 MHz channel of a dual-channel board); FM avoids ADS-B/UAT channels.
|
||||||
@@ -6326,8 +6514,8 @@ when their footprints touch or contain one another, never across a seam toleranc
|
|||||||
- Scene and CCTV selects share the default `gev-field` surface. HUD layout and
|
- Scene and CCTV selects share the default `gev-field` surface. HUD layout and
|
||||||
draw colour retain compact density. Radio filter, tuner gain and FM frequency
|
draw colour retain compact density. Radio filter, tuner gain and FM frequency
|
||||||
use the same base while retaining their feature-specific skins and layout.
|
use the same base while retaining their feature-specific skins and layout.
|
||||||
- The CCTV inline number editor uses `createNumberInput` with its existing
|
- The CCTV inline number editor is rendered by its pose chip component with
|
||||||
precision and inline density. Enter/blur commit, Escape cancellation, frozen
|
its existing precision and inline density. Enter/blur commit, Escape cancellation, frozen
|
||||||
camera base, camera-switch guards and disposal remain with CCTV controls.
|
camera base, camera-switch guards and disposal remain with CCTV controls.
|
||||||
- Options, values, native keyboard/typeahead, disabled attributes and browser
|
- Options, values, native keyboard/typeahead, disabled attributes and browser
|
||||||
validity remain native. Frequency bounds, tuner commit timing and calibration
|
validity remain native. Frequency bounds, tuner commit timing and calibration
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Director authoring and file sharing
|
# Director authoring and file sharing
|
||||||
|
|
||||||
Open Scenes and select a scene and shot. Existing capture, update, playback and
|
Open Authoring and select a scene and shot. Existing capture, update, playback and
|
||||||
whole-project export controls remain available. Authored scene content, source
|
whole-project export controls remain available. Authored scene content, source
|
||||||
links, assets and contributor credit are preserved.
|
links, assets and contributor credit are preserved.
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ field definitions. This is a basic draft editor, not a node graph or path editor
|
|||||||
SHARE SCENE exports only the selected scene. Download scene JSON preserves its
|
SHARE SCENE exports only the selected scene. Download scene JSON preserves its
|
||||||
camera, timing, layers, anchors, manifests, interactions and attribution, but
|
camera, timing, layers, anchors, manifests, interactions and attribution, but
|
||||||
contains no asset bytes. The recipient needs the referenced source registrations
|
contains no asset bytes. The recipient needs the referenced source registrations
|
||||||
and files. Existing EXPORT PRESETS continues to export the entire project.
|
and files. EXPORT (formerly EXPORT PRESETS) continues to export the entire project.
|
||||||
|
|
||||||
For a bundle, select the declared pack files or a folder, then Download asset
|
For a bundle, select the declared pack files or a folder, then Download asset
|
||||||
bundle. Folder paths match a manifest path either directly or after removing the
|
bundle. Folder paths match a manifest path either directly or after removing the
|
||||||
|
|||||||
+30
-7
@@ -25,17 +25,39 @@ label.textContent = t(FEED_STATE_LABELS[state]);
|
|||||||
|
|
||||||
A module the server, tools or other portable code also imports must not load the catalogs: it imports `msg` from `src/i18n/marker.js`, which only marks the text, and the UI translates it with `t()` where it shows it. (Older modules keep a local `const msg = (text) => text`, which works the same.)
|
A module the server, tools or other portable code also imports must not load the catalogs: it imports `msg` from `src/i18n/marker.js`, which only marks the text, and the UI translates it with `t()` where it shows it. (Older modules keep a local `const msg = (text) => text`, which works the same.)
|
||||||
|
|
||||||
|
Vue views call the same `t()` and `tn()`. The page's vue-i18n instance (`src/ui/i18n.js`), installed in every view, holds the active locale, and `t()`, `tn()` and the formatters read it from there, so a view that translates while rendering renders again when the locale changes. Views don't use vue-i18n's own `t`: English is the key, `context` picks an entry and plural forms follow `Intl.PluralRules`, which only Core's functions do.
|
||||||
|
|
||||||
`msg()` exists for two reasons. `npm run i18n` finds messages only as string literals inside `t(…)`, `tn(…)` and `msg(…)`, so `t(FEED_STATE_LABELS[state])` alone tells it nothing. And a `t()` call at module load runs before the page has picked its language, so it would freeze the English.
|
`msg()` exists for two reasons. `npm run i18n` finds messages only as string literals inside `t(…)`, `tn(…)` and `msg(…)`, so `t(FEED_STATE_LABELS[state])` alone tells it nothing. And a `t()` call at module load runs before the page has picked its language, so it would freeze the English.
|
||||||
|
|
||||||
## Markup
|
## Markup
|
||||||
|
|
||||||
|
Vue views translate in the template, so their text renders in the active locale:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<span>{{ t('Normal') }}</span>
|
||||||
|
<button :title="t('Pin visual presets')">…</button>
|
||||||
|
<option value="all">{{ t('All', { context: 'radio tag' }) }}</option>
|
||||||
|
```
|
||||||
|
|
||||||
|
Static page markup that no view renders (the loading screen in `index.html`, a host's own markup) opts in with attributes instead, and `npm run i18n` rejects them in a Vue template:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<span data-i18n>Normal</span>
|
<span data-i18n>Normal</span>
|
||||||
<button title="Pin visual presets" data-i18n-title>…</button>
|
<button title="Pin visual presets" data-i18n-title>…</button>
|
||||||
<input placeholder="Search any location..." data-i18n-placeholder />
|
<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 at boot and again after every language switch.
|
||||||
|
|
||||||
|
## Switching language
|
||||||
|
|
||||||
|
The language picker switches in place, without a reload. A view that calls `t()` while it renders follows by itself. Code that translates text and hands it on (to a store, a published view object, a layer's state, a DOM node) must word it again when the language changes:
|
||||||
|
|
||||||
|
- **Present again.** `onLocaleChange(listener)` from `src/i18n/index.js` calls the listener after each change and returns the function that removes it; call that at teardown. The listener presents the view again from what it keeps, as `layerPanel.js` and `cockpitController.js` do.
|
||||||
|
- **Keep the English, translate when presenting.** Store `msg()` text or a function that words it, not its translation: the voice control takes `update(part, { text: () => t('MIC') })`, the scene director's status and the SDR receiver's messages keep such functions, and `imagerySplit.js` takes its titles that way.
|
||||||
|
- **Translated constants in a view** go in `computed`, not in a plain `const` set up once.
|
||||||
|
|
||||||
|
A toast already on screen keeps its words.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
@@ -66,16 +88,16 @@ An empty translation is reported, and so is one left as the English, plural form
|
|||||||
|
|
||||||
`npm run i18n` and `npm test` fail on UI text that is not marked:
|
`npm run i18n` and `npm test` fail on UI text that is not marked:
|
||||||
|
|
||||||
- **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`.
|
- **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`. In a Vue template, static text or a static `title`, `aria-label` or `placeholder` (rather than `t()`), and any `data-i18n*` attribute.
|
||||||
- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `title` and the like; passed to `setAttribute('title' | 'aria-label' | …)`, `createTextNode`, `showToast` and other notice helpers; assigned to a layer's `error`, `lastError`, `loadingLabel`, `statusMessage` or `status` (a plain `error = …` too); or kept in `label:`, `title:`, `status:` and similar properties. Not reported: declarations (`let status = …`), default values, ternary branches and lower-case codes (`'auth-failed'`). The full list is `JS_SINKS` in `scripts/i18n.mjs`.
|
- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `title` and the like; passed to `setAttribute('title' | 'aria-label' | …)`, `createTextNode`, `showToast` and other notice helpers; assigned to a layer's `error`, `lastError`, `loadingLabel`, `statusMessage` or `status` (a plain `error = …` too); or kept in `label:`, `title:`, `status:` and similar properties. Not reported: declarations (`let status = …`), default values, ternary branches and lower-case codes (`'auth-failed'`). The full list is `JS_SINKS` in `scripts/i18n.mjs`.
|
||||||
|
|
||||||
Fix it by marking the text. When the text must stay as it is, say so where it lives:
|
Fix it by marking the text. When the text must stay as it is, say so where it lives:
|
||||||
|
|
||||||
| Case | How |
|
| Case | How |
|
||||||
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
|
||||||
| Codes, units, product names in markup (`0.9 dB`, `ADS-B · 1090`, `OpenSky`) | `translate="no"` on the element; it covers everything inside, as in HTML |
|
| Codes, units, product names in markup (`0.9 dB`, `ADS-B · 1090`, `OpenSky`) | `translate="no"` on the element; it covers everything inside, as in HTML |
|
||||||
| Markup whose text code sets through `t()` (a status that starts as `OFF`) | `data-i18n-runtime` on the element |
|
| Page markup whose text code sets through `t()` (a status that starts as `OFF`) | `data-i18n-runtime` on the element (not in a Vue template) |
|
||||||
| A literal in code that is not UI copy (a product name, sample data) | `// i18n-ignore: <reason>` on the line or the line above |
|
| A literal in code that is not UI copy (a product name, sample data) | `// i18n-ignore: <reason>` on the line or the line above |
|
||||||
|
|
||||||
Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowercase identifiers are skipped automatically, and so is `src/tools/`, whose text is written for AI clients rather than the page.
|
Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowercase identifiers are skipped automatically, and so is `src/tools/`, whose text is written for AI clients rather than the page.
|
||||||
|
|
||||||
@@ -84,6 +106,7 @@ Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowerca
|
|||||||
| File | What it holds |
|
| File | What it holds |
|
||||||
| ------------------------------- | ------------------------------------------------------------------------------ |
|
| ------------------------------- | ------------------------------------------------------------------------------ |
|
||||||
| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language |
|
| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language |
|
||||||
|
| `src/ui/i18n.js` | the page's vue-i18n instance: the active locale and the catalogs |
|
||||||
| `src/i18n/marker.js` | `msg` alone, for modules the server or tools import (no catalogs) |
|
| `src/i18n/marker.js` | `msg` alone, for modules the server or tools import (no catalogs) |
|
||||||
| `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot |
|
| `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot |
|
||||||
| `src/i18n/locales/<code>.json` | one catalog per language, keyed by English text |
|
| `src/i18n/locales/<code>.json` | one catalog per language, keyed by English text |
|
||||||
|
|||||||
+2
-1
@@ -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.
|
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
|
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
|
## Keyed live sources
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Scene document format
|
# Scene document format
|
||||||
|
|
||||||
Scenes exports a UTF-8 JSON project. Version 5 adds [data packs](DIRECTOR-DATA-PACKS.md)
|
Authoring exports a UTF-8 JSON project. Version 5 adds [data packs](DIRECTOR-DATA-PACKS.md)
|
||||||
and is the write format. Versions 1, 2, 3, 4 and unversioned legacy files migrate on
|
and is the write format. Versions 1, 2, 3, 4 and unversioned legacy files migrate on
|
||||||
import without converting their existing shots into authored moves. Unknown versions or unsupported fields are rejected with
|
import without converting their existing shots into authored moves. Unknown versions or unsupported fields are rejected with
|
||||||
a field path before the current project, selection or saved bytes change.
|
a field path before the current project, selection or saved bytes change.
|
||||||
|
|||||||
+36
-31
@@ -90,35 +90,35 @@ probability of about 255 in 256 (many of them already fail as base64url).
|
|||||||
|
|
||||||
## Field registry (top level)
|
## Field registry (top level)
|
||||||
|
|
||||||
| # | State key | Type | Default | Encoding |
|
| # | State key | Type | Default | Encoding |
|
||||||
| --- | ---------------------------- | ----- | ----------- | ------------------------------------------------------- |
|
| --- | ---------------------------- | ----- | ----------- | ----------------------------------------------------------------- |
|
||||||
| 1 | `view.kind` | enum | see below | 0 place, 1 thing |
|
| 1 | `view.kind` | enum | see below | 0 place, 1 thing |
|
||||||
| 2 | `lat` | coord | 0 | zigzag of degrees × 10⁴ |
|
| 2 | `lat` | coord | 0 | zigzag of degrees × 10⁴ |
|
||||||
| 3 | `lon` | coord | 0 | zigzag of degrees × 10⁴ |
|
| 3 | `lon` | coord | 0 | zigzag of degrees × 10⁴ |
|
||||||
| 4 | `alt` | sint | 800 | whole metres |
|
| 4 | `alt` | sint | 800 | whole metres |
|
||||||
| 5 | `heading` | angle | 0 | whole degrees, see angles |
|
| 5 | `heading` | angle | 0 | whole degrees, see angles |
|
||||||
| 6 | `pitch` | sint | −35 | whole degrees |
|
| 6 | `pitch` | sint | −35 | whole degrees |
|
||||||
| 7 | `roll` | angle | 0 | whole degrees |
|
| 7 | `roll` | angle | 0 | whole degrees |
|
||||||
| 8 | `sharedAtMs` | epoch | none | zigzag seconds since 2026-01-01T00:00:00Z |
|
| 8 | `sharedAtMs` | epoch | none | zigzag seconds since 2026-01-01T00:00:00Z |
|
||||||
| 9 | booleans | flags | 0 | bit set when the value differs from default |
|
| 9 | booleans | flags | 0 | bit set when the value differs from default |
|
||||||
| 10 | `layerState.enabledLayerIds` | LEN | none | layer bitset, see layers |
|
| 10 | `layerState.enabledLayerIds` | LEN | none | layer bitset, see layers |
|
||||||
| 11 | `layerState.options` | LEN | none | packed option triples, see layers |
|
| 11 | `layerState.options` | LEN | none | packed option triples, see layers |
|
||||||
| 12 | `panelState` | LEN | none | packed panel entries, see panels |
|
| 12 | `panelState` | LEN | none | packed panel entries, see panels |
|
||||||
| 13 | thing | LEN | none | sub-message, see thing views |
|
| 13 | thing | LEN | none | sub-message, see thing views |
|
||||||
| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow |
|
| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow (retired) |
|
||||||
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm |
|
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm |
|
||||||
| 16 | `hudVariant` | enum | style | tactical, operator, minimal, cyber |
|
| 16 | `hudVariant` | enum | style | tactical, operator (retired), minimal, cyber |
|
||||||
| 17 | `bloomIntensity` | uint | style | |
|
| 17 | `bloomIntensity` | uint | style | |
|
||||||
| 18 | `bloomVersion` | uint | 2 | |
|
| 18 | `bloomVersion` | uint | 2 | |
|
||||||
| 19 | `sharpenIntensity` | uint | style | |
|
| 19 | `sharpenIntensity` | uint | style | |
|
||||||
| 20 | `detectionMode` | enum | style | OFF, SPARSE, BALANCED, DENSE |
|
| 20 | `detectionMode` | enum | style | OFF, SPARSE, BALANCED, DENSE |
|
||||||
| 21 | `detectionDensity` | uint | style | one of 0, 25, 50, 75, 100 |
|
| 21 | `detectionDensity` | uint | style | one of 0, 25, 50, 75, 100 |
|
||||||
| 22 | `detectionAllocation` | enum | style | ELASTIC, WEIGHTED |
|
| 22 | `detectionAllocation` | enum | style | ELASTIC, WEIGHTED |
|
||||||
| 23 | `detectionFadePct` | uint | style | 0–40 |
|
| 23 | `detectionFadePct` | uint | style | 0–40 |
|
||||||
| 24 | `detectionOutsideOpacityPct` | uint | style | 0–100 |
|
| 24 | `detectionOutsideOpacityPct` | uint | style | 0–100 |
|
||||||
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
|
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
|
||||||
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
|
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
|
||||||
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
|
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
|
||||||
|
|
||||||
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
|
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.
|
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
|
defaults, while a decoder that does not know the style uses Normal's; adding a
|
||||||
style also extends the pinned style-defaults table.
|
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
|
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
|
||||||
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
|
`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 |
|
| thermal | sensitivity 0.85, bloom 0.2, mode 0.33, pixelation 1, palette 0 |
|
||||||
| anime | saturation 1, edgeThick 0.5 |
|
| anime | saturation 1, edgeThick 0.5 |
|
||||||
| noir | contrastAmt 1.2, grainAmt 0.5, vignetteAmt 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
|
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
|
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
|
values in the codec (see Style defaults). The same applies
|
||||||
to `SHARE_VIEW_LAYER_OPTION_ABSENT` and the panel defaults.
|
to `SHARE_VIEW_LAYER_OPTION_ABSENT` and the panel defaults.
|
||||||
4. **Codes are append-only.** Enum lists, entity kinds, flag bits, panel
|
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
|
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
|
(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
|
boolean takes the next flag bit instead, which costs nothing when it is at
|
||||||
|
|||||||
+2
-1
@@ -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,
|
aircraft or satellite to follow. `createView` builds and bounds one,
|
||||||
`viewToParams` and `viewFromParams` write and read it in the share-link
|
`viewToParams` and `viewFromParams` write and read it in the share-link
|
||||||
format the app restores, and `viewUrl` gives the address that opens it. The
|
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
|
link yet. An aircraft's `follow` may ask for its cockpit view
|
||||||
(`cockpit: true`); links cannot carry that and open the app following it.
|
(`cockpit: true`); links cannot carry that and open the app following it.
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
# Translating God's Eye View
|
# 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), **Arabic** (`ar`), **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
|
## How it works
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ Every other language is one flat JSON file, `src/i18n/locales/<code>.json`, keye
|
|||||||
|
|
||||||
A `tn()` entry is keyed by the English plural form and holds one string per `Intl.PluralRules` category of that language. A missing entry falls back to English.
|
A `tn()` entry is keyed by the English plural form and holds one string per `Intl.PluralRules` category of that language. A missing entry falls back to English.
|
||||||
|
|
||||||
The page picks its language from `?lang=`, then the choice stored by the language picker (the globe button in the bottom-right corner), then the browser languages, then English. `?lang=` is not stored, and copied share links leave it out. Switching reloads the page.
|
The page picks its language from `?lang=`, then the choice stored by the language picker (the globe button in the bottom-right corner), then the browser languages, then English. `?lang=` is not stored, and copied share links leave it out. Switching applies at once, without a reload.
|
||||||
|
|
||||||
## The check
|
## The check
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ Edit `src/i18n/locales/<code>.json` and run `npm run i18n`. To see your change,
|
|||||||
|
|
||||||
1. Create `src/i18n/locales/<code>.json`, fill it from `npm run i18n -- --missing <code>` (start from `{}`), and translate every entry.
|
1. Create `src/i18n/locales/<code>.json`, fill it from `npm run i18n -- --missing <code>` (start from `{}`), and translate every entry.
|
||||||
2. Import it in `src/i18n/index.js` and add it to `CATALOGS`.
|
2. Import it in `src/i18n/index.js` and add it to `CATALOGS`.
|
||||||
3. Add the code to `LOCALES` and its own-language name to `LOCALE_NAMES` in `src/i18n/locale.js`. Right-to-left languages also go in `RTL_LOCALES`.
|
3. Add the code to `LOCALES` and its own-language name to `LOCALE_NAMES` in `src/i18n/locale.js`. Right-to-left languages also go in `RTL_LOCALES`; the chrome mirrors through logical CSS properties (`inset-inline-*`, `margin-inline-*`), and `src/ui/styles/rtl.css` holds what those cannot express (see the Arabic notes). If the language should keep Western digits, map it in `INTL_TAGS` in `locale.js`.
|
||||||
4. Add `src/i18n/locales/<code>.json` next to `ru.json` in `scripts/package-boundaries.json`.
|
4. Add `src/i18n/locales/<code>.json` next to `ru.json` in `scripts/package-boundaries.json`.
|
||||||
5. Write `docs/translations/<code>.md`: glossary, plural forms, units and style (copy the structure of `ru.md`).
|
5. Write `docs/translations/<code>.md`: glossary, plural forms, units and style (copy the structure of `ru.md`).
|
||||||
6. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths.
|
6. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths.
|
||||||
@@ -63,4 +63,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth
|
|||||||
|
|
||||||
## Language notes
|
## 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`: [Arabic](./translations/ar.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.
|
||||||
@@ -11,12 +11,18 @@ scene playback and voice. It delegates; new behavior belongs with its owner.
|
|||||||
| `cockpitCoordinator.js` | Cockpit entry/rollback, readouts and the single Display portal |
|
| `cockpitCoordinator.js` | Cockpit entry/rollback, readouts and the single Display portal |
|
||||||
| `visualSettings.js` | Effects, detection overrides, visual restoration and render-loop lifetime |
|
| `visualSettings.js` | Effects, detection overrides, visual restoration and render-loop lifetime |
|
||||||
| `panelChrome.js` | Disclosure, docking, layout and Cockpit panel restoration |
|
| `panelChrome.js` | Disclosure, docking, layout and Cockpit panel restoration |
|
||||||
|
| `panelNavigation.js` | Desktop panel rails: each side's offered panels and the open one |
|
||||||
| `aircraftDisplay.js` | Shared commercial/military 3D display preferences and controls |
|
| `aircraftDisplay.js` | Shared commercial/military 3D display preferences and controls |
|
||||||
| `layerBindings.js` | Data-manager attachment, selected-tracker adoption and camera-entry listeners |
|
| `layerBindings.js` | Data-manager attachment, selected-tracker adoption and camera-entry listeners |
|
||||||
| `displayBindings.js` | Keyboard/display subscriptions and frame-rate monitor lifetime |
|
| `displayBindings.js` | Keyboard/display subscriptions and frame-rate monitor lifetime |
|
||||||
| `shareRestoration.js` | Initial share/layer restoration and cancellation |
|
| `shareRestoration.js` | Initial share/layer restoration and cancellation |
|
||||||
| `shellFeedback.js` | Loading feedback, notices and toast lifetime |
|
| `shellFeedback.js` | Loading feedback, notices and toast lifetime |
|
||||||
|
|
||||||
|
Owners that present UI do so through Vue: they publish plain view objects to a
|
||||||
|
Pinia store in `src/ui/stores/`, views in `src/ui/views/` render them, and user
|
||||||
|
actions return through the handlers the owner connects to that store. See
|
||||||
|
[Building UI](../CONTRIBUTING.md#building-ui).
|
||||||
|
|
||||||
Owners receive named services, elements, operations and readers. They do not
|
Owners receive named services, elements, operations and readers. They do not
|
||||||
receive the application shell itself. Readers resolve a replaceable collaborator
|
receive the application shell itself. Readers resolve a replaceable collaborator
|
||||||
at use time; operations cross an ownership boundary without copying its state.
|
at use time; operations cross an ownership boundary without copying its state.
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
# Vue migration
|
||||||
|
|
||||||
|
The application UI moves from static templates plus imperative DOM wiring to
|
||||||
|
Vue 3 components with Pinia stores. The migration keeps the experience the
|
||||||
|
same: element ids, classes, the global stylesheet, translations, keyboard
|
||||||
|
shortcuts, voice actions, share links and the phone layout all behave as
|
||||||
|
before. The one intended change is the panel rail described below.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Views are Vue single-file components; state that several views read lives in
|
||||||
|
Pinia stores rather than in DOM classes.
|
||||||
|
- The owners listed in [UI-OWNERSHIP.md](UI-OWNERSHIP.md) keep their
|
||||||
|
responsibilities. A component replaces DOM construction; it does not add a
|
||||||
|
second owner of the same state.
|
||||||
|
- Public methods used by layers, scene playback, voice and host applications
|
||||||
|
(`setPanelCollapsed`, `setContextMode`, `setStyle`, share and location
|
||||||
|
subscriptions) keep their signatures.
|
||||||
|
- Ids and classes used by browser QA and host applications stay stable until a
|
||||||
|
host can use an explicit extension point instead.
|
||||||
|
|
||||||
|
## The panel rail
|
||||||
|
|
||||||
|
Each desktop sidebar gets a floating icon rail that lists every panel available
|
||||||
|
on that side. Selecting an icon opens that panel; selecting it again closes it.
|
||||||
|
One panel is open per side at a time, so the rail replaces the accordion
|
||||||
|
stacking. Panels that are not available in the current mode (for example
|
||||||
|
Weather before a weather layer is active) are shown disabled or omitted as the
|
||||||
|
panel's own availability rule dictates. The rail hides with the rest of the
|
||||||
|
chrome in Cockpit, scene playback, recording and the clean view.
|
||||||
|
|
||||||
|
Phones keep the existing bottom tab bar and sheets; the rail is desktop only.
|
||||||
|
|
||||||
|
Voice and keyboard openings go through the same panel store, so they follow the
|
||||||
|
one-panel rule too. Share links continue to record which panels are open.
|
||||||
|
|
||||||
|
## Component kit
|
||||||
|
|
||||||
|
`src/ui/kit/` holds the generic components: `UiRail`, `UiTooltip`, `UiIcon`,
|
||||||
|
`UiButton`, `UiPanel`, `UiSegmented`, `UiSlider`, `UiChip`, `UiSelect`,
|
||||||
|
`UiBadge` and `UiEmpty` so far, with more joining as the controls they serve
|
||||||
|
are converted. One component
|
||||||
|
serves every use of a control, with props for the differences (`UiPanel`'s
|
||||||
|
`sideways`, `glow`, `scroll`), rather than a copy per panel. The components
|
||||||
|
are styled by the existing stylesheet and custom properties; a kit class such as
|
||||||
|
`ui-button--icon` carries no Core style yet, and a converted control keeps
|
||||||
|
its own classes beside it. They contain no application state.
|
||||||
|
`UiPanel` takes a `title`, slots and `v-model:collapsed`, but its
|
||||||
|
`collapsed` drives only the button and `ui-panel--collapsed`: the root's
|
||||||
|
`collapsed` class, which hides the body, is drawn at once by `v-panel-state`
|
||||||
|
from the panel chrome store, and there is no `closable`, since Core's panels collapse rather than close.
|
||||||
|
`UiSegmented` adds `keyboard` (`none`, `arrows`, or `radio` with
|
||||||
|
Home and End), since each of Core's groups keeps its own keyboard policy, and
|
||||||
|
`itemClass`/`activeClass` for Core's segment classes. A key moves focus at
|
||||||
|
once and asks for a change only when it lands on another option. Every
|
||||||
|
segmented group in Core is a `UiSegmented`; the old `segmentedControl.js`
|
||||||
|
adapter is gone.
|
||||||
|
`UiButton` adds `pressedClass`, Core's class on a pressed toggle (most use
|
||||||
|
`active`); its pressed classes toggle through `classList`, so a class other
|
||||||
|
code adds (a voice highlight) survives a press. Every pressed toggle in the
|
||||||
|
views is a `UiButton` except Bhote Koshi's cinematic button and the
|
||||||
|
tilt-map button, whose owners still write their state into the DOM. Core's
|
||||||
|
pressable chips and pills (rail card chips, map sources, cities and places)
|
||||||
|
are `UiButton`s too; `UiChip` is a non-interactive label chip, used by
|
||||||
|
the voice card.
|
||||||
|
`UiSelect` renders the bare native select (Core's labels sit outside it or
|
||||||
|
come as an `aria-label`) with `v-model`, `options` and `disabled`;
|
||||||
|
options that carry their own attributes come through the slot, a caller's
|
||||||
|
`@change` still reaches the select, and `hold` works as in `UiSlider`.
|
||||||
|
Without `hold`, a select takes its model back after its own change renders,
|
||||||
|
so a pick its owner refuses goes back; an owner that answers later (the SDR
|
||||||
|
gain) takes `hold`. Every select in the views is a `UiSelect`.
|
||||||
|
`UiBadge` takes a `tone` and carries Core's `gev-status-badge`; its
|
||||||
|
density variants and colours stay caller classes, and it renders the CCTV
|
||||||
|
source and calibration badges and the rail card badges. `UiEmpty` takes
|
||||||
|
a `title` and `hint`, but Core's empty states are one line, so the title
|
||||||
|
is the text itself rather than a bold heading; the Scene shot list, mission
|
||||||
|
roster and imagery strip use it. The locale picker's and the mission tables'
|
||||||
|
empty rows stay a paragraph and table cells.
|
||||||
|
`UiSlider` keeps the native range input rather than drawing a
|
||||||
|
`role="slider"` track, so Core's keyboard, form behaviour and styling stay;
|
||||||
|
it takes `v-model`, `min`, `max`, `step` and `disabled`, emits a
|
||||||
|
number, and adds `hold` (never rewrite a focused handle, and take the model
|
||||||
|
back on blur) and `unstyled` (the browser's own range, as Bhote Koshi's
|
||||||
|
are, without Core's slider surface). A caller's own input, change and blur
|
||||||
|
handlers still reach the input, and a null model leaves it as it is. It is
|
||||||
|
a functional render-function component, so the portable `./ui/input` rows
|
||||||
|
use it without a component compiler. Every range in the views is a
|
||||||
|
`UiSlider` except the radio tuner's: its owner moves the handle back to the
|
||||||
|
selected station on a drag's first input without changing the slot, which
|
||||||
|
a functional component would not render again.
|
||||||
|
|
||||||
|
Controls share one skin, so every panel reads the same. Every scrolling
|
||||||
|
surface carries the kit's `.ui-scroll`, which draws the thin
|
||||||
|
themed scrollbar from `--scroll-thumb` and `--scroll-track`; the Cyber theme
|
||||||
|
sets those two tokens, and Cockpit's HUD windows keep their accent tint. A
|
||||||
|
surface without it would draw the platform's light scrollbar, because the
|
||||||
|
page declares no dark `color-scheme`. Selects and number fields are
|
||||||
|
`.gev-field`, which draws its own chevron in place of the platform's arrow
|
||||||
|
and keeps native spinners dark; ranges are `.gev-quantitative-slider`, with
|
||||||
|
a feature supplying only its accent.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
1. **Foundation.** Vue, Pinia and the Vite Vue plugin join the browser build
|
||||||
|
(`build/vite.js`, so host applications receive them too). One Vue runtime is
|
||||||
|
created by the shell (`src/ui/vueRuntime.js`) with one Pinia instance that
|
||||||
|
every view shares; disposing the shell unmounts them. Stores are added
|
||||||
|
when a view needs one, mirroring state an owner already publishes, so
|
||||||
|
there is never a second owner of the same state.
|
||||||
|
2. **Rail.** `PanelChrome` enforces one panel per side; the panel store
|
||||||
|
mirrors each side's panels and the rail renders from it. The rail panels
|
||||||
|
are `RailPanel`s: the kit's `UiPanel` drawing Core's panel markup, bound to the `gev-panel-chrome`
|
||||||
|
store, with their titles rendered by `t()` rather than `data-i18n`. Each panel's
|
||||||
|
collapse button, the dock tray toggles and the
|
||||||
|
dock pins render the open and pinned state PanelChrome presents to the
|
||||||
|
`gev-panel-chrome` store, and their clicks call it there. Each panel's
|
||||||
|
root draws its own state from there too (`v-panel-state`: `collapsed`,
|
||||||
|
`dock-pinned`, `dock-pinned-top`, `layout-auto-collapsed`,
|
||||||
|
`accordion-collapsed`): PanelChrome, the rails and the accordion present
|
||||||
|
it rather than writing classes. The directive applies it in a synchronous
|
||||||
|
watcher, not on Vue's next render, because the rails measure a panel in
|
||||||
|
the same task that opens or closes it. PanelChrome and its preference
|
||||||
|
restore, and the phone shell, read a panel's state back from the store
|
||||||
|
(`_shownPanel`; the markup's classes before its first presentation), so
|
||||||
|
once a panel is presented a class written outside the store no longer
|
||||||
|
counts. The rails, the accordion and the hover disclosure
|
||||||
|
(`panelDisclosure.js`), which act within the same task as a layout, read
|
||||||
|
the classes the directive has just drawn. Done.
|
||||||
|
3. **Panels.** Each template and its wiring become a component, keeping ids,
|
||||||
|
classes and CSS; text a template renders with `t()` drops its `data-i18n`
|
||||||
|
marker. The page places each view with a
|
||||||
|
`.gev-view` host (`display: contents`), and `mountApplicationViews()`
|
||||||
|
(`src/ui/views/index.js`) renders the view into it before the page is
|
||||||
|
translated and wired. `createApplication()` calls it too, removing the
|
||||||
|
hosts of any views its `omitViews` names. Views belong to the page and are
|
||||||
|
unmounted only when a later call omits them. The loading screen is static markup in the page so it
|
||||||
|
paints before any script runs. Markup
|
||||||
|
tests read `renderApplicationHtml()` from `src/testSupport`, which renders
|
||||||
|
the views on the server. Templates compile with Vue's `preserve`
|
||||||
|
whitespace, which keeps the whitespace between elements as a single
|
||||||
|
space, so a view renders as its template did; no view markup sits under a
|
||||||
|
`white-space: pre` rule. Application views (`src/ui/views/*View.vue`) are
|
||||||
|
kept out of Prettier; other components are formatted with strict
|
||||||
|
whitespace sensitivity, so their source spacing is what renders. The
|
||||||
|
markup has moved, unchanged:
|
||||||
|
every template except the loading screen is a view.
|
||||||
|
|
||||||
|
Next each view takes over its wiring: markup an owner builds with
|
||||||
|
`createElement` becomes component markup that renders from a Pinia store
|
||||||
|
the owner publishes to, and user actions come back to the owner through
|
||||||
|
the store, so the owner keeps every decision. Component text uses
|
||||||
|
`t()`, which follows the locale as the view renders; the views carry no
|
||||||
|
`data-i18n*` markers, which `npm run i18n` rejects in a template. Shared builders become components first and
|
||||||
|
keep their imperative entry points until their owners are components:
|
||||||
|
the rail cards, card blocks and timeline (`src/ui/rail/`) render through
|
||||||
|
Vue's synchronous `render()` behind `createRailCards()`,
|
||||||
|
`createRailCardBlocks()` and `createRailTimeline()`. Order: Scenes (done: `SceneOptions` and
|
||||||
|
`SceneShotList` over the `gev-scenes` store; the panel's buttons, status
|
||||||
|
line, progress bar and the runtime chip render the panel SceneControls
|
||||||
|
presents there, and the buttons and file chooser call its actions), Weather (done: the owner
|
||||||
|
renders `WeatherReadout` from the view it computes), Recent imagery
|
||||||
|
(done: `RecentImageryReadout` and its day cards, the same way),
|
||||||
|
Street level (done: `StreetLevelPanel` renders the `gev-street-level`
|
||||||
|
store that `StreetLevelControls` publishes to; the controls keep their
|
||||||
|
listeners, EXPAND, focus and resizing), CCTV (done: `CctvPanel` renders
|
||||||
|
the whole panel from the `gev-cctv` store, including the camera picker,
|
||||||
|
the inline pose editor, the frame preview, source badge and typed
|
||||||
|
summary; `CctvControls` keeps its listeners, the frame requests and the
|
||||||
|
live video surface), Layers (done: `LayerPanel` presents each row and
|
||||||
|
renders `LayerRows` synchronously; keyed chips and list items keep focus
|
||||||
|
through the refresh their own click triggers, and clicks re-read the live
|
||||||
|
descriptor before acting), Display (in progress: `DisplayPanel` renders
|
||||||
|
the HUD, Sonar, Scope, Celestial, detection, bloom, sharpen and the
|
||||||
|
active style's parameter sliders from the `gev-display` store, which
|
||||||
|
VisualSettings publishes to and answers the sliders through, and the 3D
|
||||||
|
aircraft group, the Draw controls and the imported file list, which
|
||||||
|
AircraftDisplay, the draw tool and the file import publish beside it;
|
||||||
|
the controls call DisplayBindings, the draw tool and the file import
|
||||||
|
through the store's `connect`/`act`, the newest owner of an action
|
||||||
|
answering it; `GeoImport` hands keyboard focus on before a focused row
|
||||||
|
goes, with `v-focus-safe-children`; the
|
||||||
|
style parameter rows render through Vue behind `createStyleParameters()`,
|
||||||
|
as a render function so the portable `./ui/input` entry needs no
|
||||||
|
component compiler; the other groups follow one owner at a time),
|
||||||
|
Context and Radio (in progress: `RadioPanel` renders the directory row,
|
||||||
|
station card, transport, volume and playback line from the `gev-radio`
|
||||||
|
store that RadioControls publishes to and whose controls it answers
|
||||||
|
through the store's `connect`/`act` (the shared `createOwners()`; a
|
||||||
|
refused station tag gets the kept tag back from `act`), and `SdrCard` the
|
||||||
|
local RTL-SDR card that LocalSdrControls publishes beside it and answers
|
||||||
|
the same way; fields a person edits take their value through
|
||||||
|
`v-field-value`, which never writes a focused field;
|
||||||
|
`RadioTuner` renders the directory tuner, where RadioControls keeps the
|
||||||
|
drag and its slot, takes the slider's pointer, key and value events
|
||||||
|
through the store and publishes the band, readouts, slider and needle,
|
||||||
|
and the component lays out the tick tape against the dial's measured
|
||||||
|
width and lays out the snapped needle before it drops the drag class;
|
||||||
|
`ContextView` renders the mode switch and the nearby sites
|
||||||
|
search from the `gev-context` store, which ContextControls reaches only
|
||||||
|
through its `publish` option, so `./ui/context` needs no Vue;
|
||||||
|
`AwarenessPanel` renders the Contacts panel that the awareness layer
|
||||||
|
publishes through `subscribePanel()`, the first layer to hand its rail
|
||||||
|
content to a view, and `MissionRoster` and `MissionDetail` the Space
|
||||||
|
Missions roster and selected mission that the launches layer publishes
|
||||||
|
the same way), the command dock (in progress: the style buttons, the
|
||||||
|
dock's style mini status and the ACTIVE STYLE corner render the style
|
||||||
|
VisualSettings publishes to the `gev-display` store; the buttons keep
|
||||||
|
their owner's listeners; the map source chips and status badge render
|
||||||
|
what `createMapSourceControls()` publishes beside it, so `./ui/maps`
|
||||||
|
needs no Vue; the location tray's city and landmark pills, mini status
|
||||||
|
and search error render what `LocationControls` publishes to the
|
||||||
|
`gev-location` store, and the search field keeps its listeners; beside
|
||||||
|
the voice control, the dock teleports each voice pointer reticle to the
|
||||||
|
body where its `PointerReticle` presents it in the `gev-pointer-reticle`
|
||||||
|
store), the phone shell (`PhoneTabBar` renders the tabs and open sheet the shell
|
||||||
|
publishes to the `gev-display` store, teleporting the tab bar and sheet
|
||||||
|
backdrop to the end of the page from the first publish until the shell
|
||||||
|
is destroyed; the shell takes their clicks from the document), the
|
||||||
|
imagery dividers (`ImagerySplitView`, in the scene chrome, teleports each
|
||||||
|
before/after divider that `createImagerySplit` publishes to the
|
||||||
|
`gev-imagery-split` store to the body; the split keeps the
|
||||||
|
drag, keyboard, CSS position and scene split), the status chips
|
||||||
|
(the top-center loading status and the road network and camera grid
|
||||||
|
chips render what ShellFeedback and CctvControls publish to the
|
||||||
|
`gev-status` store; `SplitFlapText` plays the departure-board flip that
|
||||||
|
`splitFlap.js` plans, writing only the label's one text node; the toast
|
||||||
|
renders what each owner's `createToast()` publishes there, on that
|
||||||
|
owner's own timer, and the title bar's FPS readout what
|
||||||
|
`createFrameRateMonitor()` publishes), the globe actions
|
||||||
|
(`GlobeActions` renders the top-center bar; its clear, share, tilt,
|
||||||
|
north-up and reset buttons, and the cockpit's reset, call the shell
|
||||||
|
through the `gev-globe-actions` store, which also carries a clear or a
|
||||||
|
reset under way and the camera's tilt and heading
|
||||||
|
that `bindCameraOrientationControls` publishes each drawn frame it
|
||||||
|
changes, which only `GlobeCompass` renders; an owner publishes only
|
||||||
|
while it answers the button, so a shell replaced by a newer one leaves
|
||||||
|
its state alone), the recording safe frame (the scene chrome renders
|
||||||
|
the frame `RecordingControls` publishes to the `gev-recording` store),
|
||||||
|
the Scene actions panel (`SceneInteractions`
|
||||||
|
teleports what the scene interactions owner publishes to the
|
||||||
|
`gev-scene-interactions` store into the viewer's container; the owner
|
||||||
|
keeps the pick handler and focuses the picked action), the data-pack
|
||||||
|
attribution cards (`DataPacks` teleports what the data-pack presentations
|
||||||
|
publish to the `gev-data-packs` store into the viewer's container and
|
||||||
|
stops a removed card's player), the wind inspection marker
|
||||||
|
(`WindInspectionMarker` teleports the marker into the wind layer's
|
||||||
|
container where `createWindInspectionMarker()`, now in
|
||||||
|
`src/layers/wind/inspectionMarker.js` so the portable `./tools` entry
|
||||||
|
needs no Vue, places it after each rendered frame; an unchanged frame
|
||||||
|
publishes nothing), the Space Missions replay vehicle
|
||||||
|
(`MissionReplayOverlay` teleports into the globe container what the
|
||||||
|
launches layer presents through `subscribeReplayOverlay()`, which the
|
||||||
|
application shell publishes to the `gev-mission-replay` store, so the
|
||||||
|
portable `./layers/launches` entry needs no Vue), the Bhote Koshi
|
||||||
|
reconstruction panel (`BhoteKoshiPanel`, last in the context rail,
|
||||||
|
renders what the event layer publishes to the `gev-bhote-koshi-panel`
|
||||||
|
store: each control's text, hidden, disabled, value and attributes as
|
||||||
|
the layer writes them, unchanged frames sending nothing; the rail takes
|
||||||
|
`bhote-event-active` while it shows), the full-globe celestial ring
|
||||||
|
(`CelestialRing` teleports into the globe container what `CelestialRing`
|
||||||
|
in `src/celestialRing.js` publishes to the `gev-celestial-ring` store and
|
||||||
|
hands its two canvases back for the owner to paint; the application shell
|
||||||
|
passes the store in, so the keyhole helpers other packages import need no
|
||||||
|
Vue), the screen-space map annotations (`ScreenAnnotations` teleports to
|
||||||
|
the body the board the screen annotation renderer publishes to the
|
||||||
|
`gev-screen-annotations` store after each rendered frame: each mark's SVG
|
||||||
|
parts as attribute records, the fading marks until they leave; the view
|
||||||
|
hands back the callout text and mark groups the renderer measures, and
|
||||||
|
the tracked-entity fade is measured once the frame is in the document;
|
||||||
|
the styles it injected now live in `src/ui/styles/annotations.css`), the
|
||||||
|
visible map target list (the scene chrome renders the buttons and status
|
||||||
|
the world overlay presents through `subscribeWorldOverlayActions()`, which
|
||||||
|
the application shell publishes to the `gev-world-overlay-actions` store
|
||||||
|
and answers with `activateWorldOverlayAction()`; the module needs no Vue
|
||||||
|
for the portable transit entry, and its card and detection canvases stay
|
||||||
|
painting surfaces it draws on, like the Cesium canvas), the Global
|
||||||
|
Context compass and contact bearings (`AwarenessDirection` teleports into
|
||||||
|
the globe container what the awareness layer presents through
|
||||||
|
`subscribeDirectionOverlay()`, which the application shell publishes to
|
||||||
|
the `gev-awareness-direction` store, so the portable `./layers/awareness`
|
||||||
|
entry needs no Vue), the Intel HUD (the `intel-hud` view renders
|
||||||
|
`#intel-hud`, its corners, edges and bottom bar from the `gev-hud`
|
||||||
|
store, which IntelHUD publishes to through its `publish` option: the
|
||||||
|
readouts, the typing summary, the REC blink, visibility, layout variant
|
||||||
|
and style colors; the vessel layer presents the selected vessel's
|
||||||
|
readout through `src/hudReadouts.js` instead of writing into the HUD),
|
||||||
|
the first-run mission launcher (the welcome view renders the card's
|
||||||
|
visibility, busy state, status line, copy and scroll fade from the
|
||||||
|
`gev-first-run` store that `initFirstRunExperience` publishes to, and its
|
||||||
|
choices, DISMISS and "Don't show this again" call the launcher's
|
||||||
|
controls; a refused save hands the tick back to the view to revert. The
|
||||||
|
launcher still reads the rendered card for its hit test, keyboard trap,
|
||||||
|
focus and overflow measurement), then
|
||||||
|
Cockpit (in progress: `CockpitBriefing` renders the briefing card's
|
||||||
|
pages, cycle toggle, regional news and local conditions, and
|
||||||
|
`CockpitContext` the Contact card's scope, navigation, counts, nearest
|
||||||
|
contact and weather toggle, `CockpitView` the instruments, destination
|
||||||
|
arrow and vision selector, and `CockpitRoute` the flight plan card, from
|
||||||
|
the `gev-cockpit` store that the Cockpit controller publishes to,
|
||||||
|
starting from `cockpitBriefPages.js`, `cockpitContextState.js` and
|
||||||
|
`cockpitInstrumentState.js`, which both read; the odometer readouts
|
||||||
|
(`CockpitRollingValue`) keep their throttle and roll in
|
||||||
|
`cockpitRollingValue.js`. `CockpitSignalList` renders the live signal
|
||||||
|
rows; `v-focus-safe-children` turns Vue's move of the focused row into
|
||||||
|
moves of the rows around it, and hands focus to the shown briefing tab
|
||||||
|
before the focused row is removed, so a keyed list reorders several times
|
||||||
|
a second without dropping keyboard focus. The instruments publish on
|
||||||
|
every HUD update (about 10 Hz); in a dev build their Vue patch costs
|
||||||
|
about 0.5 ms, close to the DOM writes it replaced. The controller also
|
||||||
|
publishes which surfaces are shown: the entry button and TR-3B chip in
|
||||||
|
the Context panel, the HUD with its exit and reset buttons and briefing
|
||||||
|
card, and the Contact card. Focus moves to the exit button, and back to
|
||||||
|
the entry, only after Vue renders them. The cards' collapse is part of
|
||||||
|
that presence too: each card's `data-collapsed` and its toggle's words
|
||||||
|
and icon render from it. The controls keep the controller's listeners,
|
||||||
|
and the cards' layout stays with the controller, which reads their
|
||||||
|
visibility when it lays them out. Its layout pass publishes the utility
|
||||||
|
strip's measured fit (the HUD's anchor and height limits, whether only the
|
||||||
|
expanded control fits and which control is hidden from assistive
|
||||||
|
technology), which `CockpitView` and `CockpitUtilityControls` render; the
|
||||||
|
briefing and Contact cards' positions stay imperative, because the same
|
||||||
|
pass measures them straight after writing them.
|
||||||
|
`CockpitUtilityControls` renders the Display
|
||||||
|
and Radio launchers, which popover is open and the compact Radio
|
||||||
|
controls from the `gev-radio` store that RadioControls publishes to and
|
||||||
|
answers through. The Cockpit display portal publishes the Display
|
||||||
|
popover's slots to the `gev-display` store while Cockpit is on, and
|
||||||
|
`DisplayPanel` teleports its HUD, Detection, Parameters and 3D groups into
|
||||||
|
them and back; the portal keeps each surface's scroll position and puts
|
||||||
|
back focus the move dropped).
|
||||||
|
Layers that write into the right rail directly gain a slot instead.
|
||||||
|
|
||||||
|
4. **Cleanup.** The build-time template expansion is retired: `index.html`
|
||||||
|
lists the view hosts and the loading screen itself. Every owner that
|
||||||
|
showed UI state through hand-written DOM writes now publishes to a store
|
||||||
|
that a view renders, apart from the imperative parts this document
|
||||||
|
records surface by surface; [Building UI](../CONTRIBUTING.md#building-ui)
|
||||||
|
now requires the pattern for new UI. The page-level markup tests read
|
||||||
|
`renderApplicationHtml()`, the real views rendered on the server, and new
|
||||||
|
tests mount components; three tests still read a `.vue` file's source
|
||||||
|
(`cyberTheme`, `panelStorageDocs`, `geoFileImport`). The views translate with `t()`
|
||||||
|
while rendering, and vue-i18n holds the locale they read; the attribute
|
||||||
|
translation pass is left for static page markup (the loading screen and a
|
||||||
|
host's own markup). An explicit API for hosts to register panels and rail items is
|
||||||
|
deferred until a host adds one: no host does today (the hosted
|
||||||
|
application's account interface is its own markup), and the first such
|
||||||
|
panel, the hosted timeline, will shape it.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Unit tests: stores and components run under `npm test`. Single-file
|
||||||
|
components are compiled by a test loader; component tests render with
|
||||||
|
`@vue/test-utils` and `happy-dom`. `npm test` registers the loader; to run
|
||||||
|
one file that reaches a component (including anything importing the
|
||||||
|
shell), use `node --import=./build/registerVue.js --test <file>`.
|
||||||
|
- Never pass happy-dom nodes to `assert.equal` or `deepEqual`: a failure
|
||||||
|
prints both operands, and a node reaches the whole page and window, which
|
||||||
|
runs the process out of memory. Compare nodes with `assertSameNode()`
|
||||||
|
(`src/testSupport/domAssert.mjs`).
|
||||||
|
- Panels lifted out of a view keep its verbatim markup in
|
||||||
|
`src/ui/views/panels/`, which Prettier leaves alone like the views.
|
||||||
|
- Visual parity: before/after screenshots of every panel at desktop and phone
|
||||||
|
widths, in the default and Cyber themes, with the rail as the only expected
|
||||||
|
difference.
|
||||||
|
- Browser QA: the selector-based `scripts/qa-*.mjs` suites keep passing.
|
||||||
|
- `npm run format:check`, `npm run check:boundaries`, `npm run i18n` and
|
||||||
|
`npm run build`.
|
||||||
+24
-15
@@ -12,7 +12,7 @@ A rail panel uses an outer owner with its existing stable `id`, matching
|
|||||||
`data-panel-id`, and `panel-collapsible` class. Its first non-glow child is the
|
`data-panel-id`, and `panel-collapsible` class. Its first non-glow child is the
|
||||||
surface. Keep the header and any outward popup outside the scrolling body:
|
surface. Keep the header and any outward popup outside the scrolling body:
|
||||||
|
|
||||||
```html
|
```vue
|
||||||
<section
|
<section
|
||||||
id="example-panel"
|
id="example-panel"
|
||||||
data-panel-id="example-panel"
|
data-panel-id="example-panel"
|
||||||
@@ -21,14 +21,11 @@ surface. Keep the header and any outward popup outside the scrolling body:
|
|||||||
<div data-panel-surface>
|
<div data-panel-surface>
|
||||||
<header data-panel-header>
|
<header data-panel-header>
|
||||||
<h2 class="panel-title panel-surface-title">Example</h2>
|
<h2 class="panel-title panel-surface-title">Example</h2>
|
||||||
<button
|
<PanelCollapseButton
|
||||||
class="panel-collapse-btn panel-surface-control"
|
class="panel-surface-control"
|
||||||
data-collapse-target="example-panel"
|
target="example-panel"
|
||||||
aria-expanded="true"
|
:name="t('Example')"
|
||||||
aria-label="Collapse Example"
|
/>
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
<div data-panel-body data-rail-scroller>
|
<div data-panel-body data-rail-scroller>
|
||||||
<label>Filter <input class="panel-surface-control" type="text" /></label>
|
<label>Filter <input class="panel-surface-control" type="text" /></label>
|
||||||
@@ -92,15 +89,27 @@ Do not copy the rail example's `.panel-collapse-btn` class into the dock:
|
|||||||
existing dock rules intentionally hide that class. The generic surface skin
|
existing dock rules intentionally hide that class. The generic surface skin
|
||||||
does not replace the dock's disclosure policy.
|
does not replace the dock's disclosure policy.
|
||||||
|
|
||||||
The existing `PanelChrome` binds `.panel-collapse-btn[data-collapse-target]`
|
A panel with Core's standard header (glow, title, badges, divider, actions
|
||||||
at initialization and routes state through `setPanelCollapsed`. For additions
|
and collapse button) is a `RailPanel` (`src/ui/rail/`): the kit's `UiPanel`
|
||||||
|
bound to the `gev-panel-chrome` store, with its state classes drawn by
|
||||||
|
`v-panel-state` and its button's click asking `PanelChrome` to toggle it.
|
||||||
|
A panel with its own header uses `PanelCollapseButton`, which renders the
|
||||||
|
collapse button (`.panel-collapse-btn`) from the open state `PanelChrome`
|
||||||
|
presents to that store; until then, both show their `startCollapsed` state,
|
||||||
|
which should match the markup's `collapsed` class.
|
||||||
|
Either way, the click reaches `PanelChrome`'s `setPanelCollapsed`. `PanelChrome` collects the buttons'
|
||||||
|
targets at initialization and binds each panel's Escape. For additions
|
||||||
present at startup, keep those hooks and the existing chrome lifecycle. A
|
present at startup, keep those hooks and the existing chrome lifecycle. A
|
||||||
dynamically mounted panel must bind and dispose its own disclosure using
|
dynamically mounted panel must own its disclosure: its own state, button and
|
||||||
`bindPanelDisclosure` / `collapsePanelOnEscape` from `panelDisclosure.js`, with
|
Escape, bound and disposed with `bindPanelDisclosure` /
|
||||||
the application owning state changes. Do not rerun the whole application chrome
|
`collapsePanelOnEscape` from `panelDisclosure.js` (which bind Escape only). Do not rerun the whole application chrome
|
||||||
initializer just to add one panel. `layoutLeftPanelRail` and
|
initializer just to add one panel. `layoutLeftPanelRail` and
|
||||||
`layoutRightPanelRail` accept arbitrary direct `data-panel-id` children; their
|
`layoutRightPanelRail` accept arbitrary direct `data-panel-id` children; their
|
||||||
host owns layout scheduling, observations and collapse callbacks. Dynamic
|
host owns layout scheduling, observations and collapse callbacks. A rail
|
||||||
|
never writes a panel's classes: it calls `onCollapse(panel, {collapsed,
|
||||||
|
auto})`, and the host draws that state (the application presents it to the
|
||||||
|
panel chrome store, which each root's `v-panel-state` draws at once, so the
|
||||||
|
rail can measure the panel in the same task). Dynamic
|
||||||
content that changes size may require an owner-scheduled layout pass or an
|
content that changes size may require an owner-scheduled layout pass or an
|
||||||
owned `ResizeObserver`; adding CSS attributes does not register new observers.
|
owned `ResizeObserver`; adding CSS attributes does not register new observers.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Arabic (`ar`)
|
||||||
|
|
||||||
|
Catalog: `src/i18n/locales/ar.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
|
||||||
|
|
||||||
|
Translation sources: abiyeenzo (#915).
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- Modern Standard Arabic, neutral across regions. Address the user with plain imperatives: «انقر»، «فعّل»، «أعد المحاولة».
|
||||||
|
- Arabic has no letter case: an UPPERCASE English HUD label becomes the same short Arabic label. Keep labels as short as the English: «مباشر»، «متقادم»، «متوقف».
|
||||||
|
- Status words in progress use «جارٍ …» («جارٍ التحميل»، «جارٍ الضبط»، «… جارٍ تشغيل اللقطة»), so a status chip never reads as the command next to it («ضبط» is the TUNE button); finished actions use the passive («تم الحفظ»، «أُلغي التحميل»). Data that refreshes itself «يُحدَّث» (يتحدّث means "speaks"). A sentence about a `{placeholder}` whose gender and number vary (tool, layer and cohort names, call signs, vessel names) stays impersonal: «{layers}: تم التفعيل للتو»، ««{name}» قيد الاستخدام»، «{id}: لا بيانات واردة الآن»، «لم يعد {name} ضمن البيانات المحمّلة».
|
||||||
|
- A label followed by its value takes a colon, so it does not read as a sentence: «الصوت: نشط»، «السياق: {value}»، «الاحتواء: {percent}%». A named mode or control quoted in a sentence takes «»: «يظهر في وضع «كثيف» فقط».
|
||||||
|
- Arabic punctuation in Arabic text: ، ؛ ؟ and «» for quotes. Keep `·` as the HUD separator, `—` with spaces, and `…`.
|
||||||
|
- Arrows that show a sequence point the way Arabic reads: «الإشارات ← الأخبار ← المحلي», «{origin} ← {destination}».
|
||||||
|
- Product names, provider names and codes stay in Latin letters: Google, Cesium ion, CelesTrak, NOAA, FIRMS, AIS, ADS-B, GNSS, CCTV, HUD, CRT, NVG, FLIR, WHOT/BHOT, Ironbow, Street View, Mapillary. Aviation and camera abbreviations stay Latin too: HDG, FOV, BRG, CRS, GS, TRK, V/S, SQUAWK. Local place names from the Bhote Koshi scene (Rasuwagadhi, Bidur, Timure…) stay Latin, and a site known by two names takes one Arabic noun in front: «جسر Mailung / Upper Trishuli»; well-known cities and countries are written in Arabic (أوستن، طوكيو، نيبال).
|
||||||
|
- Months are the international names used across the Arab world: يناير، فبراير … أغسطس.
|
||||||
|
|
||||||
|
## Digits and direction
|
||||||
|
|
||||||
|
- **Western digits (0–9)**, as in the catalog. The HUD reads like an instrument panel, and mixing ٠–٩ with Latin codes and coordinates misreads. `intlLocale('ar')` in `src/i18n/locale.js` passes `ar-u-nu-latn` to every Intl formatter, so browsers that default to Arabic-Indic digits for an Arabic region still show 0–9.
|
||||||
|
- **Right to left.** `ar` is in `RTL_LOCALES`, so the page gets `<html dir="rtl">`. The title, style indicator, both panel stacks and their icon rails swap sides; the dock and the corner row reverse. The intel HUD (classification block, MGRS, coordinates, GSD) stays a left-to-right instrument overlay; its translated lines read right to left inside it. FM seek and the radio dial stay left to right, as on any receiver. The rails' screen-reader names follow the mirrored layout: «Left panels» is «اللوحات اليمنى». Layout rules: `src/ui/styles/rtl.css`.
|
||||||
|
- Latin runs inside Arabic sentences (product names, codes, `{placeholders}` filled with English data) are isolated by the bidi algorithm; write them as plain text, without manual direction marks.
|
||||||
|
- Position counters: a translated counter says «من» («القناة {index} من {total}», «الإطار {index} من {total}»); a spaced slash between two isolated numbers swaps them in right-to-left text. Counters the code builds from bare numbers (`01 / 25`, `1 / 3`) are shown left to right by `rtl.css`.
|
||||||
|
- Code stays left to right: the JSON fields of the scene editor are LTR in every language.
|
||||||
|
- Comparison signs `<` and `>` are mirrored glyphs in right-to-left text («< 2%» shows as «> 2%»). Write them as words: «أقل من 2%»، «أكثر من 10%»، «أقل من ساعة». A legend's open upper bound («300+», «-40») is isolated by `formatScaleLabel()`, so its sign stays where it is written; `formatScaleRange()` isolates the unit after the range too («°C», not «C°»).
|
||||||
|
|
||||||
|
## Units
|
||||||
|
|
||||||
|
Distances and speeds take Arabic abbreviations after the number, with a space: كم، م، كم/س، كم/ث، م/ث، ميل/س، عقدة (knots)، قدم (feet)، مم. Time: ث (seconds)، د (minutes)، س (hours)، يوم; compact age labels («3d», «5D AGO») use ي: «{count} ي»، «قبل {count} ي», since a plain string cannot make يوم agree with the count. Area: آكر (acres, the value is not converted; فدان is a different unit). Money: the word دولار after the amount, not a bare `$`: «{amount} مليون دولار». Scientific and radio units stay Latin: MHz، dB، dBFS، dBZ، hPa، MW، MiB. `°` and `%` stay. Hemisphere letters in coordinates: ش (N)، ج (S)، ق (E)، غ (W), as in `21.33ش`.
|
||||||
|
|
||||||
|
## Plurals
|
||||||
|
|
||||||
|
`Intl.PluralRules('ar')` uses all six CLDR categories, and a `tn()` entry needs every one:
|
||||||
|
|
||||||
|
| Form | Counts | Example |
|
||||||
|
| ------- | --------------------------- | ------------- |
|
||||||
|
| `zero` | 0 | 0 مصدر |
|
||||||
|
| `one` | 1 | مصدر واحد (1) |
|
||||||
|
| `two` | 2 | مصدران (2) |
|
||||||
|
| `few` | 3–10, 103–110… | 3 مصادر |
|
||||||
|
| `many` | 11–99, 111–199… | 11 مصدرًا |
|
||||||
|
| `other` | 100–102, 200–202, fractions | 100 مصدر |
|
||||||
|
|
||||||
|
Every form must keep `{count}`. For one and two, Arabic names the quantity with the noun itself (مصدر واحد، مصدران), so the number follows in parentheses. `few` takes the plural noun; `many` the accusative singular (مصدرًا، مفتاحًا); `zero` and `other` the genitive singular. When a word next to the number cannot agree in every form, or a short chip has no room, put the number after a colon instead: «القريبة: {count}»، «الملفات المحددة: {count}». Where neither fits, «من أصل {total} من السجلات» agrees with any number. A standalone unit label («الدقائق») is never glued after a number: the voice-timeout options («3 دقائق»، «15 دقيقة») come from `Intl.NumberFormat` unit formatting, not from the catalog.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
| English | العربية |
|
||||||
|
| ------------------------------------------ | ---------------------------------------------------------- |
|
||||||
|
| layer / data layer | طبقة / طبقة بيانات |
|
||||||
|
| feed / source | مصدر بيانات (short: مصدر) / مصدر |
|
||||||
|
| LIVE (status) / live data | مباشر / البيانات المباشرة (not حي) |
|
||||||
|
| Live ships, live detections (descriptions) | بيانات مباشرة عن السفن، رصد مباشر (not the adverb مباشرةً) |
|
||||||
|
| track, tracking | تتبّع (always with the shadda) |
|
||||||
|
| SIMULATED | محاكى (status) / محاكاة |
|
||||||
|
| stale / degraded / unavailable | متقادم / متدهور / غير متاح |
|
||||||
|
| ON / OFF (state) | مفعّل / متوقف |
|
||||||
|
| retry | إعادة المحاولة |
|
||||||
|
| key (API key) / POWER UP | مفتاح (مفتاح API) / تشغيل |
|
||||||
|
| Provider Settings | إعدادات المزوّدين |
|
||||||
|
| globe / map / map source / basemap | الكرة الأرضية / الخريطة / مصدر الخريطة / الخريطة الأساسية |
|
||||||
|
| globe map (map source drawn on the globe) | خريطة على الكرة الأرضية (not خريطة كروية) |
|
||||||
|
| tiles | بلاطات |
|
||||||
|
| aircraft / flight / military flights | طائرة / رحلة / الرحلات العسكرية |
|
||||||
|
| Live Flights / Live Vessels (layers) | تتبّع الرحلات / السفن المباشر (not الرحلات المباشرة) |
|
||||||
|
| call sign | رمز النداء |
|
||||||
|
| vessel, ship | سفينة |
|
||||||
|
| satellite | قمر صناعي |
|
||||||
|
| launch (rocket) / space mission | إطلاق / مهمة فضائية |
|
||||||
|
| contact (tracked object) | هدف |
|
||||||
|
| installation, site / mapped | منشأة، موقع / مرسوم |
|
||||||
|
| camera / CCTV camera | كاميرا / كاميرا CCTV |
|
||||||
|
| earthquake / active fire / fire perimeter | زلزال / حريق نشط / محيط حريق |
|
||||||
|
| containment (wildfire) | احتواء (not السيطرة) |
|
||||||
|
| cyclone advisory | نشرة إعصار |
|
||||||
|
| scene / shot / scene recipe | مشهد / لقطة / قالب المشهد |
|
||||||
|
| briefing / panel / dock | إحاطة / لوحة / الشريط السفلي |
|
||||||
|
| preset / visual preset | إعداد مسبق / نمط بصري |
|
||||||
|
| Scope (circular viewport mask) / keyhole | المنظار / الفتحة |
|
||||||
|
| Clean UI / EXIT CLEAN VIEW | واجهة نظيفة / الخروج من الواجهة النظيفة (not العرض) |
|
||||||
|
| cockpit | القمرة (قمرة القيادة) |
|
||||||
|
| heading / altitude / ground speed | الاتجاه (HDG) / الارتفاع / السرعة الأرضية |
|
||||||
|
| Street Level / Street View | مستوى الشارع / Street View |
|
||||||
|
| Recent Imagery | الصور الحديثة |
|
||||||
|
| Global Context / Context | السياق العالمي / السياق |
|
||||||
|
| radio station / tuner / gain | محطة / موالف / الكسب |
|
||||||
|
| broadcaster (radio stream) | محطة البث (not المحطة المُذيعة) |
|
||||||
|
| VOLUME (slider) / VOICE | مستوى الصوت / الصوت |
|
||||||
|
| decoder (SDR) | مفكّك |
|
||||||
|
| transit / bike share | النقل العام / الدراجات المشتركة |
|
||||||
|
| Previous / Next | السابق / التالي |
|
||||||
|
| compass points (N, NNE, …) | شمال، شمال شمال شرق، … |
|
||||||
|
| forecast / valid (time) / issued | توقع / عند، وقت التوقع (not صالح حتى) / صدر |
|
||||||
|
| observation / observed | رصد / مرصود |
|
||||||
|
| heard (aircraft received by the SDR) | الملتقطة |
|
||||||
|
| upstream (camera feed) | المصدر الأصلي (in every CCTV message, not bare المصدر) |
|
||||||
|
| field (weather) / model | حقل / نموذج |
|
||||||
|
| parameters / ellipsoid | المعلمات / المجسّم الإهليلجي |
|
||||||
|
| scene cloud | غيوم المشهد |
|
||||||
|
| box (Recent Imagery area) / pin | منطقة / تثبيت |
|
||||||
|
| width (box size) / view (current camera) | اتساع / العرض الحالي (عرض alone reads as display) |
|
||||||
|
| N/A | غير متاح (no abbreviation) |
|
||||||
|
| METRO (view band) / REGIONAL | حضري (not حاضرة) / إقليمي |
|
||||||
|
| THIS (voice pointer) | هذا |
|
||||||
|
| outline / mark (map annotation) | مخطط / علامة، تعليم |
|
||||||
|
| share (a view or link) | مشاركة |
|
||||||
|
| AI agent | وكيل ذكاء اصطناعي |
|
||||||
|
| spacecraft | مركبة فضائية (مركبة alone is any vehicle) |
|
||||||
|
| stage (rocket) / downrange | مرحلة / على امتداد المسار |
|
||||||
|
| landing position: CONFIRMED / PAD (RTLS) | مؤكَّد / منصة الإطلاق |
|
||||||
|
| recovery (rocket stage) | استعادة (not استرجاع) |
|
||||||
|
| Arrive at your destination (route step) | الوصول إلى وجهتك (a step, not وصلت) |
|
||||||
|
| forecast lead hours | مدة التوقع بالساعات (not ساعات المهلة) |
|
||||||
|
| cel-shaded | بتظليل كرتوني |
|
||||||
|
| (debris-)dammed lake | بحيرة خلف سدّ (ركامي) |
|
||||||
|
| frame (bring into view) | إظهار في الإطار |
|
||||||
|
| story beat | مرحلة القصة |
|
||||||
@@ -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) |
|
||||||
@@ -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) |
|
||||||
@@ -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 … |
|
||||||
@@ -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) | 監視(地球監視, 軌道監視) |
|
||||||
@@ -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 |
|
||||||
@@ -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 | посилання на стеження застаріло |
|
||||||
@@ -43,7 +43,7 @@ Translation sources: abiyeenzo (#914) and EDIT-Aeterna (#979).
|
|||||||
| briefing / panel / dock | 简报 / 面板 / 停靠栏 |
|
| briefing / panel / dock | 简报 / 面板 / 停靠栏 |
|
||||||
| preset / visual preset | 预设 / 视觉预设 |
|
| preset / visual preset | 预设 / 视觉预设 |
|
||||||
| Display / HUD layout | 显示 / HUD 布局 |
|
| Display / HUD layout | 显示 / HUD 布局 |
|
||||||
| Tactical / Operator / Minimal / Cyber | 战术 / 操作员 / 精简 / 赛博 |
|
| Tactical / Minimal / Cyber | 战术 / 精简 / 赛博 |
|
||||||
| Scope (circular viewport mask) / keyhole | 视域 |
|
| Scope (circular viewport mask) / keyhole | 视域 |
|
||||||
| Clean UI | 纯净界面 |
|
| Clean UI | 纯净界面 |
|
||||||
| cockpit | 座舱 |
|
| cockpit | 座舱 |
|
||||||
|
|||||||
+20
-18
@@ -21,31 +21,33 @@
|
|||||||
<link rel="stylesheet" href="/style.css" />
|
<link rel="stylesheet" href="/style.css" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&family=Noto+Sans+Arabic:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<!-- Icon font subset to the glyphs this app renders. Keep `icon_names` in sync
|
<!-- Icon font subset to the glyphs this app renders. Keep `icon_names` in sync
|
||||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||||
names a glyph this list is missing, because a missing glyph does not draw
|
names a glyph this list is missing, because a missing glyph does not draw
|
||||||
a box — the ligature never forms and the element renders the literal word. -->
|
a box — the ligature never forms and the element renders the literal word. -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,search,settings,skip_next,skip_previous,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- gev:template scene-chrome -->
|
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||||
|
<div class="gev-view" data-gev-view="cockpit"></div>
|
||||||
|
<div class="gev-view" data-gev-view="display-controls"></div>
|
||||||
|
<div class="gev-view" data-gev-view="command-dock"></div>
|
||||||
|
<div class="gev-view" data-gev-view="layer-panels"></div>
|
||||||
|
<div class="gev-view" data-gev-view="context"></div>
|
||||||
|
<div class="gev-view" data-gev-view="welcome"></div>
|
||||||
|
<div class="gev-view" data-gev-view="provider-settings"></div>
|
||||||
|
<!-- Intelligence HUD Overlay -->
|
||||||
|
<div class="gev-view" data-gev-view="intel-hud"></div>
|
||||||
|
|
||||||
<!-- gev:template cockpit -->
|
<!-- Loading screen -->
|
||||||
|
<div id="loading-screen">
|
||||||
<!-- gev:template display-controls -->
|
<div class="loader-content">
|
||||||
|
<span class="loader-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span>
|
||||||
<!-- gev:template command-dock -->
|
<h2 translate="no">GOD'S EYE <span class="title-accent">VIEW</span></h2>
|
||||||
|
<p class="loader-status" data-i18n>Initializing photorealistic world...</p>
|
||||||
<!-- gev:template layer-panels -->
|
</div>
|
||||||
|
</div>
|
||||||
<!-- gev:template context -->
|
|
||||||
|
|
||||||
<!-- gev:template welcome -->
|
|
||||||
|
|
||||||
<!-- gev:template provider-settings -->
|
|
||||||
|
|
||||||
<!-- gev:template hud-loading -->
|
|
||||||
|
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Generated
+759
-81
File diff suppressed because it is too large.
Load diff
+12
-5
@@ -66,13 +66,20 @@
|
|||||||
"mapillary-js": "^4.1.2",
|
"mapillary-js": "^4.1.2",
|
||||||
"mgrs": "^2.1.0",
|
"mgrs": "^2.1.0",
|
||||||
"pbf": "^5.1.2",
|
"pbf": "^5.1.2",
|
||||||
"satellite.js": "^6.0.2"
|
"pinia": "^2.3.1",
|
||||||
|
"satellite.js": "^6.0.2",
|
||||||
|
"vue": "^3.5.44",
|
||||||
|
"vue-i18n": "^11.4.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@happy-dom/global-registrator": "^20.14.6",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
|
"@vue/test-utils": "^2.5.1",
|
||||||
|
"happy-dom": "^20.14.6",
|
||||||
"prettier": "3.9.6",
|
"prettier": "3.9.6",
|
||||||
"puppeteer": "^25.10.0",
|
"puppeteer": "^25.16.0",
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.4",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.4.4",
|
||||||
"vite-plugin-cesium": "^1.2.23",
|
"vite-plugin-cesium": "^1.2.23",
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.0"
|
||||||
},
|
},
|
||||||
@@ -213,8 +220,8 @@
|
|||||||
"./voice/actions": "./src/voice/gevActions.js",
|
"./voice/actions": "./src/voice/gevActions.js",
|
||||||
"./annotations": "./src/annotations/index.js",
|
"./annotations": "./src/annotations/index.js",
|
||||||
"./context": "./src/data/contextStore.js",
|
"./context": "./src/data/contextStore.js",
|
||||||
"./build/html": {
|
"./build/register-vue": {
|
||||||
"node": "./build/application-html.js"
|
"node": "./build/registerVue.js"
|
||||||
},
|
},
|
||||||
"./application/catalog": "./src/app/catalog.js",
|
"./application/catalog": "./src/app/catalog.js",
|
||||||
"./standalone/catalog": "./src/standalone/catalog.js",
|
"./standalone/catalog": "./src/standalone/catalog.js",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { readFile, realpath } from 'node:fs/promises';
|
import { readFile, realpath } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js';
|
||||||
import { build, normalizePath } from 'vite';
|
import { build, normalizePath } from 'vite';
|
||||||
|
|
||||||
/** Build each browser or Node export group and reject imports outside its declared ownership. */
|
/** Build each browser or Node export group and reject imports outside its declared ownership. */
|
||||||
@@ -99,15 +101,20 @@ export async function checkPackageBoundaries(root) {
|
|||||||
ssr: node ? { noExternal: true } : undefined,
|
ssr: node ? { noExternal: true } : undefined,
|
||||||
logLevel: 'silent',
|
logLevel: 'silent',
|
||||||
plugins: [
|
plugins: [
|
||||||
|
vue(VUE_PLUGIN_OPTIONS),
|
||||||
{
|
{
|
||||||
name: 'check-package-ownership',
|
name: 'check-package-ownership',
|
||||||
moduleParsed(info) {
|
moduleParsed(info) {
|
||||||
if (!allowed.has(info.id)) {
|
// Plugin helpers are virtual; a single-file component's style and
|
||||||
|
// template blocks belong to the component file itself.
|
||||||
|
if (info.id.startsWith('\0')) return;
|
||||||
|
const id = info.id.split('?')[0];
|
||||||
|
if (!allowed.has(id)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Package boundary ${name} imports an unowned module: ${path.relative(root, info.id)}`,
|
`Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
seen.add(info.id);
|
seen.add(id);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"server/standalone/vite.config.js",
|
"server/standalone/vite.config.js",
|
||||||
"vite.config.js",
|
"vite.config.js",
|
||||||
"src/tooling/viteBuild.test.mjs",
|
"src/tooling/viteBuild.test.mjs",
|
||||||
|
"src/tooling/vueLoader.test.mjs",
|
||||||
"src/tooling/allowedHosts.test.mjs",
|
"src/tooling/allowedHosts.test.mjs",
|
||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
"server/providers/common/query.js",
|
"server/providers/common/query.js",
|
||||||
@@ -180,6 +181,8 @@
|
|||||||
"src/ui/panelDisclosure.js",
|
"src/ui/panelDisclosure.js",
|
||||||
"src/ui/imagerySplit.js",
|
"src/ui/imagerySplit.js",
|
||||||
"src/ui/imagerySplit.test.mjs",
|
"src/ui/imagerySplit.test.mjs",
|
||||||
|
"src/ui/imagerySplitView.test.mjs",
|
||||||
|
"src/ui/stores/imagerySplit.js",
|
||||||
"src/ui/surfaceKeyboard.js",
|
"src/ui/surfaceKeyboard.js",
|
||||||
"src/ui/surfaceKeyboard.test.mjs",
|
"src/ui/surfaceKeyboard.test.mjs",
|
||||||
"src/ui/panelRails.js",
|
"src/ui/panelRails.js",
|
||||||
@@ -197,9 +200,7 @@
|
|||||||
"scripts/qa-scope-hud.mjs",
|
"scripts/qa-scope-hud.mjs",
|
||||||
"scripts/qa-segmented-controls.mjs",
|
"scripts/qa-segmented-controls.mjs",
|
||||||
"src/ui/displayControls.js",
|
"src/ui/displayControls.js",
|
||||||
"src/ui/segmentedControl.test.mjs",
|
|
||||||
"src/ui/styles/segmented-control.css",
|
"src/ui/styles/segmented-control.css",
|
||||||
"src/ui/displayControls.test.mjs",
|
|
||||||
"src/ui/cameraOrientationControls.js",
|
"src/ui/cameraOrientationControls.js",
|
||||||
"src/ui/cameraOrientationControls.test.mjs",
|
"src/ui/cameraOrientationControls.test.mjs",
|
||||||
"src/ui/effects.js",
|
"src/ui/effects.js",
|
||||||
@@ -212,7 +213,6 @@
|
|||||||
"src/styles/thermal.js",
|
"src/styles/thermal.js",
|
||||||
"src/styles/anime.js",
|
"src/styles/anime.js",
|
||||||
"src/styles/noir.js",
|
"src/styles/noir.js",
|
||||||
"src/styles/snow.js",
|
|
||||||
"scripts/qa-visual-effects.mjs",
|
"scripts/qa-visual-effects.mjs",
|
||||||
"scripts/qa-draw-tool.mjs",
|
"scripts/qa-draw-tool.mjs",
|
||||||
"scripts/qa-geo-import.mjs",
|
"scripts/qa-geo-import.mjs",
|
||||||
@@ -298,6 +298,8 @@
|
|||||||
"src/ui/styles.js",
|
"src/ui/styles.js",
|
||||||
"src/ui/shellLifecycle.test.mjs",
|
"src/ui/shellLifecycle.test.mjs",
|
||||||
"src/testSupport/readStylesheet.mjs",
|
"src/testSupport/readStylesheet.mjs",
|
||||||
|
"src/testSupport/voiceUi.mjs",
|
||||||
|
"src/testSupport/voiceView.mjs",
|
||||||
"src/testSupport/streetLevelFakes.mjs",
|
"src/testSupport/streetLevelFakes.mjs",
|
||||||
"src/splitFlap.js",
|
"src/splitFlap.js",
|
||||||
"style.css",
|
"style.css",
|
||||||
@@ -527,6 +529,7 @@
|
|||||||
"scripts/qa-traffic-navigation.mjs",
|
"scripts/qa-traffic-navigation.mjs",
|
||||||
"src/ui/frameRateMonitor.js",
|
"src/ui/frameRateMonitor.js",
|
||||||
"src/ui/frameRateMonitor.test.mjs",
|
"src/ui/frameRateMonitor.test.mjs",
|
||||||
|
"src/ui/frameRateView.test.mjs",
|
||||||
"src/layers/cctv/lifecycle.js",
|
"src/layers/cctv/lifecycle.js",
|
||||||
"src/layers/cctv/controls.js",
|
"src/layers/cctv/controls.js",
|
||||||
"src/layers/cctv/geometry.js",
|
"src/layers/cctv/geometry.js",
|
||||||
@@ -562,6 +565,7 @@
|
|||||||
"src/data/cctvCataloniaSource.test.mjs",
|
"src/data/cctvCataloniaSource.test.mjs",
|
||||||
"src/data/cctvHk.test.mjs",
|
"src/data/cctvHk.test.mjs",
|
||||||
"src/data/cctvVegvesen.test.mjs",
|
"src/data/cctvVegvesen.test.mjs",
|
||||||
|
"src/data/cctvA22.test.mjs",
|
||||||
"src/data/cctvNyc.test.mjs",
|
"src/data/cctvNyc.test.mjs",
|
||||||
"src/data/cctvNswSource.test.mjs",
|
"src/data/cctvNswSource.test.mjs",
|
||||||
"src/data/cctvTxdotSource.test.mjs",
|
"src/data/cctvTxdotSource.test.mjs",
|
||||||
@@ -650,7 +654,6 @@
|
|||||||
"src/voice/realtimeBackend.test.mjs",
|
"src/voice/realtimeBackend.test.mjs",
|
||||||
"src/agent/worldPacket.test.mjs",
|
"src/agent/worldPacket.test.mjs",
|
||||||
"src/voice/layerSummary.js",
|
"src/voice/layerSummary.js",
|
||||||
"build/application-html.js",
|
|
||||||
"src/app/sources.js",
|
"src/app/sources.js",
|
||||||
"src/ui/composition.js",
|
"src/ui/composition.js",
|
||||||
"src/services/application.js",
|
"src/services/application.js",
|
||||||
@@ -703,7 +706,6 @@
|
|||||||
"src/ui/shareRestoration.js",
|
"src/ui/shareRestoration.js",
|
||||||
"src/ui/visualSettings.js",
|
"src/ui/visualSettings.js",
|
||||||
"src/ui/panelChrome.js",
|
"src/ui/panelChrome.js",
|
||||||
"src/ui/panelChrome.radioOwnership.test.mjs",
|
|
||||||
"src/testSupport/readShellSource.mjs",
|
"src/testSupport/readShellSource.mjs",
|
||||||
"src/ui/stateOwners.test.mjs",
|
"src/ui/stateOwners.test.mjs",
|
||||||
"src/layers/flights/records.js",
|
"src/layers/flights/records.js",
|
||||||
@@ -741,6 +743,8 @@
|
|||||||
"server/providers/openai/toolDescriptions.js",
|
"server/providers/openai/toolDescriptions.js",
|
||||||
"src/voice/session.js",
|
"src/voice/session.js",
|
||||||
"src/voice/sessionCommands.js",
|
"src/voice/sessionCommands.js",
|
||||||
|
"src/ui/stores/pointerReticle.js",
|
||||||
|
"src/voice/pointerReticleView.test.mjs",
|
||||||
"src/voice/realtimeSession.js",
|
"src/voice/realtimeSession.js",
|
||||||
"src/voice/realtimeEvents.js",
|
"src/voice/realtimeEvents.js",
|
||||||
"src/voice/session.test.mjs",
|
"src/voice/session.test.mjs",
|
||||||
@@ -898,6 +902,7 @@
|
|||||||
"scripts/qa-weather-swap.mjs",
|
"scripts/qa-weather-swap.mjs",
|
||||||
"scripts/qa-weather-teardown.mjs",
|
"scripts/qa-weather-teardown.mjs",
|
||||||
"src/layers/weather/imageryHost.test.mjs",
|
"src/layers/weather/imageryHost.test.mjs",
|
||||||
|
"src/layers/weather/display.test.mjs",
|
||||||
"src/sdr/adsbDecoder.test.mjs",
|
"src/sdr/adsbDecoder.test.mjs",
|
||||||
"src/sdr/controller.test.mjs",
|
"src/sdr/controller.test.mjs",
|
||||||
"src/sdr/decoderDiagnostics.test.mjs",
|
"src/sdr/decoderDiagnostics.test.mjs",
|
||||||
@@ -910,6 +915,8 @@
|
|||||||
"src/sources/adsbRecords.test.mjs",
|
"src/sources/adsbRecords.test.mjs",
|
||||||
"src/layers/localAdsb/index.test.mjs",
|
"src/layers/localAdsb/index.test.mjs",
|
||||||
"src/ui/localSdrControls.test.mjs",
|
"src/ui/localSdrControls.test.mjs",
|
||||||
|
"src/ui/sdrCardActions.test.mjs",
|
||||||
|
"src/ui/radioPanelActions.test.mjs",
|
||||||
"src/ui/localSdrMarkup.test.mjs",
|
"src/ui/localSdrMarkup.test.mjs",
|
||||||
"src/ui/localSdrPresentation.test.mjs",
|
"src/ui/localSdrPresentation.test.mjs",
|
||||||
"src/data/localReceiversProxy.test.mjs",
|
"src/data/localReceiversProxy.test.mjs",
|
||||||
@@ -1095,20 +1102,22 @@
|
|||||||
"server/providers/overpass/requestAdmission.test.mjs",
|
"server/providers/overpass/requestAdmission.test.mjs",
|
||||||
"src/tooling/trackArrivalObservation.test.mjs",
|
"src/tooling/trackArrivalObservation.test.mjs",
|
||||||
"src/ui/styles/status-badge.css",
|
"src/ui/styles/status-badge.css",
|
||||||
"src/ui/statusBadge.test.mjs",
|
|
||||||
"src/ui/cctvBadges.test.mjs",
|
"src/ui/cctvBadges.test.mjs",
|
||||||
"scripts/qa-status-badges.mjs",
|
"scripts/qa-status-badges.mjs",
|
||||||
"src/dockerTrustedPeers.test.mjs",
|
"src/dockerTrustedPeers.test.mjs",
|
||||||
"src/cameraGlobeRotation.test.mjs",
|
"src/cameraGlobeRotation.test.mjs",
|
||||||
"src/voice/globeResetConfirmation.test.mjs",
|
"src/voice/globeResetConfirmation.test.mjs",
|
||||||
"src/hudDms.test.mjs",
|
"src/hudDms.test.mjs",
|
||||||
|
"src/hudSummaryGate.test.mjs",
|
||||||
|
"src/voice/hudPlaceLabels.test.mjs",
|
||||||
"src/voice/panel/preferences.test.mjs",
|
"src/voice/panel/preferences.test.mjs",
|
||||||
|
"src/voice/panel/preferencesView.test.mjs",
|
||||||
|
"src/voice/voiceControlView.test.mjs",
|
||||||
"src/voice/panel/host.test.mjs",
|
"src/voice/panel/host.test.mjs",
|
||||||
"src/voice/panel/manualPanel.test.mjs",
|
"src/voice/panel/manualPanel.test.mjs",
|
||||||
"src/voice/panel/nativeActivity.test.mjs",
|
"src/voice/panel/nativeActivity.test.mjs",
|
||||||
"src/voice/panel/inactivity.test.mjs",
|
"src/voice/panel/inactivity.test.mjs",
|
||||||
"src/voice/panel/actionObserver.test.mjs",
|
"src/voice/panel/actionObserver.test.mjs",
|
||||||
"src/voice/panel/testDom.mjs",
|
|
||||||
"src/voice/realtimeCostPanel.test.mjs",
|
"src/voice/realtimeCostPanel.test.mjs",
|
||||||
"docs/SHARE-LINK-FORMAT.md",
|
"docs/SHARE-LINK-FORMAT.md",
|
||||||
"src/share/wire.test.mjs",
|
"src/share/wire.test.mjs",
|
||||||
@@ -1117,5 +1126,62 @@
|
|||||||
"src/testSupport/shareViewFixtures.mjs",
|
"src/testSupport/shareViewFixtures.mjs",
|
||||||
"src/ui/shareLinks.test.mjs",
|
"src/ui/shareLinks.test.mjs",
|
||||||
"src/ui/shareButton.test.mjs",
|
"src/ui/shareButton.test.mjs",
|
||||||
"src/share/shareLinkPath.test.mjs"
|
"src/share/shareLinkPath.test.mjs",
|
||||||
|
"src/ui/kit/UiRail.test.mjs",
|
||||||
|
"src/ui/kit/UiSegmented.vue",
|
||||||
|
"src/ui/kit/UiSegmented.test.mjs",
|
||||||
|
"src/ui/kit/UiSlider.js",
|
||||||
|
"src/ui/kit/UiSlider.test.mjs",
|
||||||
|
"src/ui/kit/UiButton.vue",
|
||||||
|
"src/ui/kit/UiButton.test.mjs",
|
||||||
|
"src/ui/kit/UiChip.vue",
|
||||||
|
"src/ui/kit/UiChip.test.mjs",
|
||||||
|
"src/ui/globeActions.test.mjs",
|
||||||
|
"src/ui/safeFrameView.test.mjs",
|
||||||
|
"src/ui/kit/UiSelect.js",
|
||||||
|
"src/ui/kit/UiSelect.test.mjs",
|
||||||
|
"src/ui/kit/UiBadge.vue",
|
||||||
|
"src/ui/kit/UiBadge.test.mjs",
|
||||||
|
"src/ui/kit/UiEmpty.vue",
|
||||||
|
"src/ui/rail/RailChips.vue",
|
||||||
|
"src/ui/displayPanelActions.test.mjs",
|
||||||
|
"src/testSupport/vueDom.mjs",
|
||||||
|
"build/vueLoader.js",
|
||||||
|
"build/registerVue.js",
|
||||||
|
"src/ui/panelNavigation.test.mjs",
|
||||||
|
"src/ui/stores/owners.js",
|
||||||
|
"src/ui/stores/owners.test.mjs",
|
||||||
|
"src/ui/stores/panels.test.mjs",
|
||||||
|
"src/ui/stores/status.js",
|
||||||
|
"src/ui/statusChipsView.test.mjs",
|
||||||
|
"src/ui/statusChipOwners.test.mjs",
|
||||||
|
"src/ui/toast.js",
|
||||||
|
"src/ui/toastView.test.mjs",
|
||||||
|
"src/ui/stores/sceneInteractions.js",
|
||||||
|
"src/scenes/dataPacks/presentationView.test.mjs",
|
||||||
|
"src/ui/stores/dataPacks.js",
|
||||||
|
"src/scenes/interactionsView.test.mjs",
|
||||||
|
"src/layers/wind/inspectionMarker.js",
|
||||||
|
"src/layers/wind/inspectionMarker.test.mjs",
|
||||||
|
"src/ui/stores/windInspection.js",
|
||||||
|
"src/ui/stores/missionReplay.js",
|
||||||
|
"src/layers/launches/replayOverlay.test.mjs",
|
||||||
|
"src/ui/stores/bhoteKoshiPanel.js",
|
||||||
|
"src/data/bhoteKoshiPanelView.test.mjs",
|
||||||
|
"src/ui/stores/celestialRing.js",
|
||||||
|
"src/celestialRingView.test.mjs",
|
||||||
|
"src/ui/stores/screenAnnotations.js",
|
||||||
|
"src/ui/stores/worldOverlayActions.js",
|
||||||
|
"src/overlays/worldOverlayActionsView.test.mjs",
|
||||||
|
"src/ui/stores/awarenessDirection.js",
|
||||||
|
"src/layers/awareness/directionOverlay.test.mjs",
|
||||||
|
"src/hudReadouts.js",
|
||||||
|
"src/ui/stores/hud.js",
|
||||||
|
"src/ui/views/intelHudView.test.mjs",
|
||||||
|
"src/testSupport/hudReadouts.mjs",
|
||||||
|
"src/ui/stores/firstRun.js",
|
||||||
|
"src/firstRunLauncherView.test.mjs",
|
||||||
|
"src/firstRunCopy.js",
|
||||||
|
"src/data/headingDegrees.test.mjs",
|
||||||
|
"src/data/distanceFormat.test.mjs"
|
||||||
]
|
]
|
||||||
+1
-1
@@ -39,7 +39,7 @@ export async function discoverRuntimeFormatFiles(root) {
|
|||||||
const files = [];
|
const files = [];
|
||||||
for (const name of new Set(candidates)) {
|
for (const name of new Set(candidates)) {
|
||||||
if (
|
if (
|
||||||
!/\.[cm]?js$/.test(name) ||
|
!/\.([cm]?js|vue)$/.test(name) ||
|
||||||
/\.test\.[cm]?js$/.test(name) ||
|
/\.test\.[cm]?js$/.test(name) ||
|
||||||
!existsSync(path.join(root, name))
|
!existsSync(path.join(root, name))
|
||||||
)
|
)
|
||||||
|
|||||||
+66
-8
@@ -8,7 +8,7 @@
|
|||||||
// lists it.
|
// lists it.
|
||||||
//
|
//
|
||||||
// Messages are the English literals passed to t(), tn() and msg() in src/,
|
// Messages are the English literals passed to t(), tn() and msg() in src/,
|
||||||
// plus the markup opted in with data-i18n* in src/ui/templates and index.html.
|
// plus the markup opted in with data-i18n* in index.html and the Vue views.
|
||||||
// It also lists UI text that is not marked at all (see findUnmarked).
|
// It also lists UI text that is not marked at all (see findUnmarked).
|
||||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
@@ -119,14 +119,17 @@ export function extractMessages() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const markup = trackedFiles('index.html', 'src/ui/templates/*.html');
|
const markup = markupFiles();
|
||||||
const TAG = /<([a-zA-Z][\w-]*)(\s[^>]*?)\/?>([^<]*)/gs;
|
const TAG = /<([a-zA-Z][\w-]*)(\s[^>]*?)\/?>([^<]*)/gs;
|
||||||
for (const file of markup) {
|
for (const file of markup) {
|
||||||
const html = readFileSync(path.join(ROOT, file), 'utf8');
|
const html = readMarkup(file);
|
||||||
for (const [, tag, attrs, text] of html.matchAll(TAG)) {
|
for (const [, tag, attrs, text] of html.matchAll(TAG)) {
|
||||||
const where = `${file} <${tag}>`;
|
const where = `${file} <${tag}>`;
|
||||||
if (/\sdata-i18n(\s|=|$)/.test(attrs))
|
if (/\sdata-i18n(\s|=|$)/.test(attrs)) {
|
||||||
add(normalize(decodeHtml(text)), where);
|
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(
|
for (const [, name] of attrs.matchAll(
|
||||||
/\sdata-i18n-(title|aria-label|placeholder)\b/g,
|
/\sdata-i18n-(title|aria-label|placeholder)\b/g,
|
||||||
)) {
|
)) {
|
||||||
@@ -153,7 +156,7 @@ const SKIP_TAGS = new Set(['script', 'style', 'svg', 'code', 'pre', 'title']);
|
|||||||
/**
|
/**
|
||||||
* User-visible markup text without data-i18n*. Skipped, with everything
|
* User-visible markup text without data-i18n*. Skipped, with everything
|
||||||
* inside: translate="no" (text that stays as is, as in HTML),
|
* inside: translate="no" (text that stays as is, as in HTML),
|
||||||
* data-i18n-runtime (code sets this text through t()), and icons
|
* data-i18n-runtime (page markup whose text code sets through t()), and icons
|
||||||
* (aria-hidden, Material Symbols).
|
* (aria-hidden, Material Symbols).
|
||||||
*/
|
*/
|
||||||
export function unmarkedMarkup(file, html) {
|
export function unmarkedMarkup(file, html) {
|
||||||
@@ -401,9 +404,12 @@ export function unmarkedScript(file, original) {
|
|||||||
*/
|
*/
|
||||||
export function findUnmarked() {
|
export function findUnmarked() {
|
||||||
const found = [];
|
const found = [];
|
||||||
for (const file of trackedFiles('index.html', 'src/ui/templates/*.html'))
|
for (const file of markupFiles()) {
|
||||||
|
found.push(...unmarkedMarkup(file, readMarkup(file)));
|
||||||
|
}
|
||||||
|
for (const file of trackedFiles('src/**/*.vue'))
|
||||||
found.push(
|
found.push(
|
||||||
...unmarkedMarkup(file, readFileSync(path.join(ROOT, file), 'utf8')),
|
...markedTemplate(file, readFileSync(path.join(ROOT, file), 'utf8')),
|
||||||
);
|
);
|
||||||
for (const file of sourceFiles().filter(
|
for (const file of sourceFiles().filter(
|
||||||
(name) => !NOT_UI.some((pattern) => pattern.test(name)),
|
(name) => !NOT_UI.some((pattern) => pattern.test(name)),
|
||||||
@@ -420,6 +426,57 @@ export function findUnmarked() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* data-i18n* in a Vue template: views translate with t() as they render, so
|
||||||
|
* their text follows the locale; the attributes are for static page markup.
|
||||||
|
* Reads the whole single-file component, so lines are the file's own.
|
||||||
|
*/
|
||||||
|
export function markedTemplate(file, source) {
|
||||||
|
const start = source.indexOf('<template>');
|
||||||
|
if (start === -1) return [];
|
||||||
|
const end = source.lastIndexOf('</template>');
|
||||||
|
// Comments and attribute values are blanked, keeping every offset.
|
||||||
|
const blank = (text) => text.replace(/[^\n]/g, ' ');
|
||||||
|
const template = source
|
||||||
|
.slice(0, end === -1 ? undefined : end)
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, blank);
|
||||||
|
const found = [];
|
||||||
|
const TAG = /<[a-zA-Z][\w-]*((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
|
||||||
|
TAG.lastIndex = start + '<template>'.length;
|
||||||
|
for (let tag; (tag = TAG.exec(template));) {
|
||||||
|
const attrs = tag[1].replace(/"[^"]*"|'[^']*'/g, blank);
|
||||||
|
const offset = tag.index + tag[0].length - 1 - tag[1].length;
|
||||||
|
for (const match of attrs.matchAll(
|
||||||
|
/(?<=\s)(?::|v-bind:)?(data-i18n[\w-]*)(?=[\s=/]|$)/g,
|
||||||
|
))
|
||||||
|
found.push({
|
||||||
|
where: `${file}:${lineAt(source, offset + match.index)}`,
|
||||||
|
text: `${match[1]}: use t() in a Vue template`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Markup checked for unmarked text: the page and its views. */
|
||||||
|
function markupFiles() {
|
||||||
|
return trackedFiles('index.html', 'src/ui/views/*.vue');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file's markup; for a single-file component, its template block without
|
||||||
|
* interpolations, which are code: their t() calls are read with the sources.
|
||||||
|
*/
|
||||||
|
function readMarkup(file) {
|
||||||
|
const source = readFileSync(path.join(ROOT, file), 'utf8');
|
||||||
|
if (!file.endsWith('.vue')) return source;
|
||||||
|
const start = source.indexOf('<template>');
|
||||||
|
const end = source.lastIndexOf('</template>');
|
||||||
|
if (start === -1) return '';
|
||||||
|
return source
|
||||||
|
.slice(start + '<template>'.length, end)
|
||||||
|
.replace(/\{\{[\s\S]*?\}\}/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
// Text that never reaches the page as UI: tool results and descriptions for
|
// Text that never reaches the page as UI: tool results and descriptions for
|
||||||
// AI clients (MCP and the voice model).
|
// AI clients (MCP and the voice model).
|
||||||
const NOT_UI = [/^src\/tools\//];
|
const NOT_UI = [/^src\/tools\//];
|
||||||
@@ -430,6 +487,7 @@ function sourceFiles() {
|
|||||||
'src/*.mjs',
|
'src/*.mjs',
|
||||||
'src/**/*.js',
|
'src/**/*.js',
|
||||||
'src/**/*.mjs',
|
'src/**/*.mjs',
|
||||||
|
'src/**/*.vue',
|
||||||
).filter(
|
).filter(
|
||||||
(file) =>
|
(file) =>
|
||||||
!file.startsWith('src/i18n/') &&
|
!file.startsWith('src/i18n/') &&
|
||||||
|
|||||||
+719
-66
File diff suppressed because it is too large.
Load diff
@@ -1603,7 +1603,7 @@ try {
|
|||||||
cockpit.setVisionMode('optical');
|
cockpit.setVisionMode('optical');
|
||||||
current.focus({ preventScroll: true });
|
current.focus({ preventScroll: true });
|
||||||
const states = [read()];
|
const states = [read()];
|
||||||
for (let index = 0; index < 7; index += 1) {
|
for (let index = 0; index < 6; index += 1) {
|
||||||
current.click();
|
current.click();
|
||||||
states.push(read());
|
states.push(read());
|
||||||
}
|
}
|
||||||
@@ -1637,36 +1637,36 @@ try {
|
|||||||
reentered,
|
reentered,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const cycleModes = ['optical', 'crt', 'nvg', 'thermal', 'anime', 'noir', 'snow', 'optical'];
|
const cycleModes = ['optical', 'crt', 'nvg', 'thermal', 'anime', 'noir', 'optical'];
|
||||||
const cycleLabels = ['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR', 'SNOW', 'NORMAL'];
|
const cycleLabels = ['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR', 'NORMAL'];
|
||||||
check(
|
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.mode)) === JSON.stringify(cycleModes)
|
||||||
&& JSON.stringify(visionCycle.states.map((state) => state.label)) === JSON.stringify(cycleLabels)
|
&& 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[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 }))),
|
JSON.stringify(visionCycle.states.map(({ mode, label, aria }) => ({ mode, label, aria }))),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'user vision changes open Display for every temporary treatment and retain selector focus',
|
'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, 6).every((state) => state.displayOpen === 'true')
|
||||||
&& visionCycle.states.slice(1, 7).every((state) => state.radioOpen === 'false')
|
&& visionCycle.states.slice(1, 6).every((state) => state.radioOpen === 'false')
|
||||||
&& visionCycle.states.slice(1, 7).every((state) => state.parametersActive)
|
&& visionCycle.states.slice(1, 6).every((state) => state.parametersActive)
|
||||||
&& visionCycle.states.slice(1, 7).every((state) => !state.parametersCollapsed)
|
&& visionCycle.states.slice(1, 6).every((state) => !state.parametersCollapsed)
|
||||||
&& visionCycle.states.slice(1, 7).every((state) => state.signalCollapsed)
|
&& visionCycle.states.slice(1, 6).every((state) => state.signalCollapsed)
|
||||||
&& visionCycle.states.every((state) => state.focusRetained),
|
&& visionCycle.states.every((state) => state.focusRetained),
|
||||||
JSON.stringify(visionCycle.states),
|
JSON.stringify(visionCycle.states),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'each Cockpit vision mode drives only its own shader and Normal clears them all',
|
'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 = {
|
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];
|
}[state.mode];
|
||||||
return Object.entries(state.intensities).every(([name, value]) =>
|
return Object.entries(state.intensities).every(([name, value]) =>
|
||||||
name === target ? value > 0 : value === 0);
|
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 }))),
|
JSON.stringify(visionCycle.states.map(({ mode, intensities }) => ({ mode, intensities }))),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
@@ -1720,6 +1720,9 @@ try {
|
|||||||
const priorSignalMaxHeight = signal.style.maxHeight;
|
const priorSignalMaxHeight = signal.style.maxHeight;
|
||||||
signal.style.maxHeight = '150px';
|
signal.style.maxHeight = '150px';
|
||||||
manager._setCockpitDisclosure(kind, true);
|
manager._setCockpitDisclosure(kind, true);
|
||||||
|
// Let Vue render the disclosure, but not the scheduled layout frame: the
|
||||||
|
// first syncSignalLayout below must be the first pass.
|
||||||
|
await Promise.resolve();
|
||||||
// One pass must be enough. Record the result of the FIRST layout call, then
|
// One pass must be enough. Record the result of the FIRST layout call, then
|
||||||
// confirm a second changes nothing, so a convergence regression cannot hide
|
// confirm a second changes nothing, so a convergence regression cannot hide
|
||||||
// behind a repeated call.
|
// behind a repeated call.
|
||||||
@@ -1751,7 +1754,7 @@ try {
|
|||||||
return state;
|
return state;
|
||||||
}, { variantName: variant, kind: openKind });
|
}, { 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']) {
|
for (const kind of ['display', 'radio']) {
|
||||||
const state = await desktopState(variant, kind);
|
const state = await desktopState(variant, kind);
|
||||||
check(
|
check(
|
||||||
@@ -1796,7 +1799,7 @@ try {
|
|||||||
manager._setCockpitDisplayPortalActive(false);
|
manager._setCockpitDisplayPortalActive(false);
|
||||||
document.body.classList.remove('cockpit-mode');
|
document.body.classList.remove('cockpit-mode');
|
||||||
await waitFrames();
|
await waitFrames();
|
||||||
standard.classList.remove('collapsed');
|
manager._presentPanel(standard, { collapsed: false });
|
||||||
standard.style.height = '120px';
|
standard.style.height = '120px';
|
||||||
standard.style.maxHeight = '120px';
|
standard.style.maxHeight = '120px';
|
||||||
standard.style.overflowY = 'auto';
|
standard.style.overflowY = 'auto';
|
||||||
@@ -1831,7 +1834,7 @@ try {
|
|||||||
cockpit.style.height = priorCockpitHeight;
|
cockpit.style.height = priorCockpitHeight;
|
||||||
cockpit.style.maxHeight = priorCockpitMaxHeight;
|
cockpit.style.maxHeight = priorCockpitMaxHeight;
|
||||||
cockpit.style.overflowY = priorCockpitOverflow;
|
cockpit.style.overflowY = priorCockpitOverflow;
|
||||||
standard.classList.toggle('collapsed', standardWasCollapsed);
|
manager._presentPanel(standard, { collapsed: standardWasCollapsed });
|
||||||
return {
|
return {
|
||||||
standardBefore,
|
standardBefore,
|
||||||
standardAfter,
|
standardAfter,
|
||||||
@@ -1851,7 +1854,7 @@ try {
|
|||||||
JSON.stringify(portalScroll),
|
JSON.stringify(portalScroll),
|
||||||
);
|
);
|
||||||
|
|
||||||
const boundary = await page.evaluate(() => {
|
const boundary = await page.evaluate(async () => {
|
||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
const hud = document.getElementById('cockpit-hud');
|
const hud = document.getElementById('cockpit-hud');
|
||||||
const signal = document.getElementById('cockpit-signal-stream');
|
const signal = document.getElementById('cockpit-signal-stream');
|
||||||
@@ -1866,6 +1869,7 @@ try {
|
|||||||
signal.hidden = false;
|
signal.hidden = false;
|
||||||
manager._setCockpitDisclosure('radio', false);
|
manager._setCockpitDisclosure('radio', false);
|
||||||
manager._setCockpitDisclosure('display', true);
|
manager._setCockpitDisclosure('display', true);
|
||||||
|
await new Promise(requestAnimationFrame);
|
||||||
const priorSignalMaxHeight = signal.style.maxHeight;
|
const priorSignalMaxHeight = signal.style.maxHeight;
|
||||||
const priorSignalHeight = signal.style.height;
|
const priorSignalHeight = signal.style.height;
|
||||||
// The strip is pinned to the viewport ceiling once the briefing card grows
|
// The strip is pinned to the viewport ceiling once the briefing card grows
|
||||||
@@ -2081,9 +2085,9 @@ try {
|
|||||||
await page.screenshot({ path: path.join(shotsDir, `cyber-resize-${height}.png`) });
|
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 signalTransition = await page.evaluate(async () => {
|
||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
const cockpit = manager.cockpitView;
|
const cockpit = manager.cockpitView;
|
||||||
const signalToggle = document.getElementById('cockpit-signal-toggle');
|
const signalToggle = document.getElementById('cockpit-signal-toggle');
|
||||||
@@ -2110,6 +2114,8 @@ try {
|
|||||||
manager._setCockpitDisclosure('radio', true);
|
manager._setCockpitDisclosure('radio', true);
|
||||||
manager._setCockpitDisclosure('radio', false);
|
manager._setCockpitDisclosure('radio', false);
|
||||||
const manualCollapseRetained = cockpit.signalCollapsed && cockpit.signalUserCollapsed;
|
const manualCollapseRetained = cockpit.signalCollapsed && cockpit.signalUserCollapsed;
|
||||||
|
// The toggle renders the published collapse on Vue's next flush.
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
return {
|
return {
|
||||||
afterExpansion,
|
afterExpansion,
|
||||||
afterRepeatedExpansion,
|
afterRepeatedExpansion,
|
||||||
@@ -2136,7 +2142,7 @@ try {
|
|||||||
&& signalTransition.signalAriaExpanded === 'false',
|
&& signalTransition.signalAriaExpanded === 'false',
|
||||||
JSON.stringify(signalTransition),
|
JSON.stringify(signalTransition),
|
||||||
);
|
);
|
||||||
const contextTransition = await page.evaluate(() => {
|
const contextTransition = await page.evaluate(async () => {
|
||||||
const cockpit = window.__godsEyeView.styleManager.cockpitView;
|
const cockpit = window.__godsEyeView.styleManager.cockpitView;
|
||||||
const priorCollapsed = cockpit.contextCollapsed;
|
const priorCollapsed = cockpit.contextCollapsed;
|
||||||
const contextToggle = document.getElementById('cockpit-context-toggle');
|
const contextToggle = document.getElementById('cockpit-context-toggle');
|
||||||
@@ -2148,6 +2154,8 @@ try {
|
|||||||
cockpit.setContextCollapsed(false);
|
cockpit.setContextCollapsed(false);
|
||||||
const afterExpansion = expandedEvents;
|
const afterExpansion = expandedEvents;
|
||||||
cockpit.setContextCollapsed(false);
|
cockpit.setContextCollapsed(false);
|
||||||
|
// The toggle renders the published collapse on Vue's next flush.
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
return {
|
return {
|
||||||
afterExpansion,
|
afterExpansion,
|
||||||
afterRepeatedExpansion: expandedEvents,
|
afterRepeatedExpansion: expandedEvents,
|
||||||
@@ -2168,33 +2176,34 @@ try {
|
|||||||
JSON.stringify(contextTransition),
|
JSON.stringify(contextTransition),
|
||||||
);
|
);
|
||||||
// Use the real connected DOM to catch focus loss caused by moving live rows.
|
// Use the real connected DOM to catch focus loss caused by moving live rows.
|
||||||
const signalFocus = await page.evaluate(() => {
|
const signalFocus = await page.evaluate(async () => {
|
||||||
const cockpit = window.__godsEyeView.styleManager.cockpitView;
|
const cockpit = window.__godsEyeView.styleManager.cockpitView;
|
||||||
|
// The rows render on Vue's next update; a frame is past it.
|
||||||
|
const show = async (items) => {
|
||||||
|
cockpit.signalItems = items;
|
||||||
|
cockpit.publishCockpitSignals();
|
||||||
|
await new Promise(requestAnimationFrame);
|
||||||
|
};
|
||||||
const originalItems = cockpit.signalItems;
|
const originalItems = cockpit.signalItems;
|
||||||
const wasCollapsed = cockpit.signalCollapsed;
|
const wasCollapsed = cockpit.signalCollapsed;
|
||||||
cockpit.setSignalCollapsed(false);
|
cockpit.setSignalCollapsed(false);
|
||||||
const item = (id) => ({ key: `qa-${id}`, tone: 'info', timestamp: Date.now(),
|
const item = (id) => ({ key: `qa-${id}`, tone: 'info', timestamp: Date.now(),
|
||||||
title: `QA ${id}`, detail: 'Keyboard refresh fixture', target: { layerId: 'flights', id } });
|
title: `QA ${id}`, detail: 'Keyboard refresh fixture', target: { layerId: 'flights', id } });
|
||||||
try {
|
try {
|
||||||
cockpit.signalItems = [item('focus-a'), item('focus-b')];
|
await show([item('focus-a'), item('focus-b')]);
|
||||||
cockpit.renderCockpitSignals();
|
|
||||||
const button = cockpit.signalList.querySelector('[data-signal-id="focus-a"]');
|
const button = cockpit.signalList.querySelector('[data-signal-id="focus-a"]');
|
||||||
button.focus();
|
button.focus();
|
||||||
const acquired = document.activeElement === button;
|
const acquired = document.activeElement === button;
|
||||||
cockpit.signalItems = [item('focus-c'), item('focus-b'), item('focus-a')];
|
await show([item('focus-c'), item('focus-b'), item('focus-a')]);
|
||||||
cockpit.renderCockpitSignals();
|
|
||||||
const retained = document.activeElement === button && button.isConnected;
|
const retained = document.activeElement === button && button.isConnected;
|
||||||
const order = [...cockpit.signalList.querySelectorAll('[data-signal-id]')].map((node) => node.dataset.signalId);
|
const order = [...cockpit.signalList.querySelectorAll('[data-signal-id]')].map((node) => node.dataset.signalId);
|
||||||
cockpit.signalItems = [item('focus-b')];
|
await show([item('focus-b')]);
|
||||||
cockpit.renderCockpitSignals();
|
|
||||||
const continuation = cockpit.briefTabs[cockpit.briefPageIndex] || cockpit.signalToggle;
|
const continuation = cockpit.briefTabs[cockpit.briefPageIndex] || cockpit.signalToggle;
|
||||||
const continued = document.activeElement === continuation;
|
const continued = document.activeElement === continuation;
|
||||||
cockpit.signalItems = [item('focus-a'), item('focus-b')];
|
await show([item('focus-a'), item('focus-b')]);
|
||||||
cockpit.renderCockpitSignals();
|
|
||||||
return { acquired, retained, order, continued, notReclaimed: document.activeElement === continuation };
|
return { acquired, retained, order, continued, notReclaimed: document.activeElement === continuation };
|
||||||
} finally {
|
} finally {
|
||||||
cockpit.signalItems = originalItems;
|
await show(originalItems);
|
||||||
cockpit.renderCockpitSignals();
|
|
||||||
cockpit.setSignalCollapsed(wasCollapsed);
|
cockpit.setSignalCollapsed(wasCollapsed);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2260,12 +2269,14 @@ try {
|
|||||||
signal.hidden = false;
|
signal.hidden = false;
|
||||||
manager._setCockpitDisclosure('display', false);
|
manager._setCockpitDisclosure('display', false);
|
||||||
manager._setCockpitDisclosure('radio', false);
|
manager._setCockpitDisclosure('radio', false);
|
||||||
|
await new Promise(requestAnimationFrame);
|
||||||
manager.cockpitView.syncSignalLayout();
|
manager.cockpitView.syncSignalLayout();
|
||||||
const collapsed = {
|
const collapsed = {
|
||||||
display: displayControl.getBoundingClientRect().toJSON(),
|
display: displayControl.getBoundingClientRect().toJSON(),
|
||||||
radio: radioControl.getBoundingClientRect().toJSON(),
|
radio: radioControl.getBoundingClientRect().toJSON(),
|
||||||
};
|
};
|
||||||
display.click();
|
display.click();
|
||||||
|
await new Promise(requestAnimationFrame);
|
||||||
manager.cockpitView.syncSignalLayout();
|
manager.cockpitView.syncSignalLayout();
|
||||||
return {
|
return {
|
||||||
collapsed,
|
collapsed,
|
||||||
@@ -2317,13 +2328,13 @@ try {
|
|||||||
const styleManager = window.__godsEyeView.styleManager;
|
const styleManager = window.__godsEyeView.styleManager;
|
||||||
const styleBefore = styleManager.activeStyle;
|
const styleBefore = styleManager.activeStyle;
|
||||||
// A Cockpit-only choice that Reset must undo.
|
// A Cockpit-only choice that Reset must undo.
|
||||||
styleManager.cockpitView.setVisionMode('snow');
|
styleManager.cockpitView.setVisionMode('anime');
|
||||||
window.__qaCockpitReset = {
|
window.__qaCockpitReset = {
|
||||||
calls: 0,
|
calls: 0,
|
||||||
original: manager.resetToGlobeView,
|
original: manager.resetToGlobeView,
|
||||||
subjectId: awareness?.getContextSnapshot?.()?.subject?.id || null,
|
subjectId: awareness?.getContextSnapshot?.()?.subject?.id || null,
|
||||||
styleBefore,
|
styleBefore,
|
||||||
snowDuringCockpit: styleManager.stages?.snow?.uniforms?.intensity,
|
animeDuringCockpit: styleManager.stages?.anime?.uniforms?.intensity,
|
||||||
};
|
};
|
||||||
manager.resetToGlobeView = function qaCountedCockpitReset(...args) {
|
manager.resetToGlobeView = function qaCountedCockpitReset(...args) {
|
||||||
window.__qaCockpitReset.calls += 1;
|
window.__qaCockpitReset.calls += 1;
|
||||||
@@ -2360,7 +2371,7 @@ try {
|
|||||||
height,
|
height,
|
||||||
subjectPreserved: Boolean(qa.subjectId && subjectId === qa.subjectId),
|
subjectPreserved: Boolean(qa.subjectId && subjectId === qa.subjectId),
|
||||||
styleRestored: gev.styleManager.activeStyle === qa.styleBefore,
|
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.
|
// Cockpit-time intensities are Cockpit's own, so check against the map preset.
|
||||||
visionRestored: Object.entries(gev.styleManager.stages || {}).every(([name, stage]) =>
|
visionRestored: Object.entries(gev.styleManager.stages || {}).every(([name, stage]) =>
|
||||||
name === gev.styleManager.activeStyle
|
name === gev.styleManager.activeStyle
|
||||||
@@ -2376,7 +2387,7 @@ try {
|
|||||||
&& resetState.resetHidden
|
&& resetState.resetHidden
|
||||||
&& Math.abs(resetState.height - 18_000_000) < 150_000
|
&& Math.abs(resetState.height - 18_000_000) < 150_000
|
||||||
&& resetState.subjectPreserved
|
&& resetState.subjectPreserved
|
||||||
&& resetState.snowDuringCockpit > 0
|
&& resetState.animeDuringCockpit > 0
|
||||||
&& resetState.styleRestored
|
&& resetState.styleRestored
|
||||||
&& resetState.visionRestored,
|
&& resetState.visionRestored,
|
||||||
JSON.stringify(resetState),
|
JSON.stringify(resetState),
|
||||||
|
|||||||
@@ -474,12 +474,22 @@ async function contacts() {
|
|||||||
|
|
||||||
// SEARCH NEARBY SITES, as the user presses it.
|
// SEARCH NEARBY SITES, as the user presses it.
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
document.getElementById('toast').textContent = '';
|
const toast = document.getElementById('toast');
|
||||||
|
const before = toast.firstChild;
|
||||||
document.getElementById('installations-search-btn').click();
|
document.getElementById('installations-search-btn').click();
|
||||||
|
window.__qaToastBefore = before;
|
||||||
});
|
});
|
||||||
|
// Every toast show writes a new text node, even for a repeated message.
|
||||||
await page
|
await page
|
||||||
.waitForFunction(
|
.waitForFunction(
|
||||||
() => document.getElementById('toast').textContent.trim().length > 0,
|
() => {
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
return (
|
||||||
|
toast.classList.contains('visible') &&
|
||||||
|
toast.firstChild !== window.__qaToastBefore &&
|
||||||
|
toast.textContent.trim().length > 0
|
||||||
|
);
|
||||||
|
},
|
||||||
{ timeout: 20_000 },
|
{ timeout: 20_000 },
|
||||||
)
|
)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|||||||
@@ -427,6 +427,9 @@ try {
|
|||||||
shot.interactions = [];
|
shot.interactions = [];
|
||||||
fields[0].value = JSON.stringify(scene);
|
fields[0].value = JSON.stringify(scene);
|
||||||
fields[1].value = JSON.stringify(shot);
|
fields[1].value = JSON.stringify(shot);
|
||||||
|
// Typing fires input; the dialog reads its fields from those events.
|
||||||
|
for (const field of fields)
|
||||||
|
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
});
|
});
|
||||||
await page.click('[data-director-apply-details]');
|
await page.click('[data-director-apply-details]');
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
|
|||||||
@@ -88,6 +88,70 @@ try {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
for (const [name, passed] of Object.entries(result)) check(name, passed);
|
for (const [name, passed] of Object.entries(result)) check(name, passed);
|
||||||
|
const visibilityRestored = await page.evaluate(async () => {
|
||||||
|
// QA layer registration exists only in Vite dev builds.
|
||||||
|
if (typeof window.__gevQaRegisterLayer !== 'function') return 'skipped';
|
||||||
|
const { sceneDirector: director, dataManager } = window.__godsEyeView;
|
||||||
|
const layerId = 'director-seek-visibility-qa';
|
||||||
|
window.__gevQaRegisterLayer(dataManager, {
|
||||||
|
id: layerId,
|
||||||
|
name: 'Director seek visibility QA',
|
||||||
|
updateInterval: -1,
|
||||||
|
init() {},
|
||||||
|
enable() {},
|
||||||
|
disable() {},
|
||||||
|
update() {},
|
||||||
|
destroy() {},
|
||||||
|
getStats: () => ({ count: 0 }),
|
||||||
|
});
|
||||||
|
const scene = {
|
||||||
|
id: 'visibility-qa',
|
||||||
|
title: 'Visibility QA',
|
||||||
|
shots: [
|
||||||
|
{
|
||||||
|
id: 'visibility-shot',
|
||||||
|
title: 'Visibility shot',
|
||||||
|
durationSec: 0.4,
|
||||||
|
holdSec: 0,
|
||||||
|
camera: director.styleManager.getCameraState(),
|
||||||
|
visual: director.styleManager.getVisualState(),
|
||||||
|
layers: { [layerId]: { enabled: true } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
director._project.scenes.push(scene);
|
||||||
|
try {
|
||||||
|
const loaded = await director.loadShot(scene.id, scene.shots[0].id, {
|
||||||
|
flyDuration: 0.2,
|
||||||
|
});
|
||||||
|
const disabled =
|
||||||
|
(await dataManager.setEnabled(layerId, false, { origin: 'user' })) &&
|
||||||
|
!dataManager.layers.get(layerId).enabled;
|
||||||
|
const sought = await director.seekScene(scene.id, 0.5);
|
||||||
|
return (
|
||||||
|
loaded?.started &&
|
||||||
|
disabled &&
|
||||||
|
sought &&
|
||||||
|
dataManager.layers.get(layerId).enabled
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
director.stopScene();
|
||||||
|
director._project.scenes = director._project.scenes.filter(
|
||||||
|
({ id }) => id !== scene.id,
|
||||||
|
);
|
||||||
|
await window.__gevQaUnregisterLayer(dataManager, layerId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (visibilityRestored === 'skipped') {
|
||||||
|
console.log(
|
||||||
|
'[SKIP] same-shot seek visibility needs a Vite dev server (QA_BASE_URL)',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
check(
|
||||||
|
'same-shot seek restores manually changed layer visibility',
|
||||||
|
visibilityRestored,
|
||||||
|
);
|
||||||
|
}
|
||||||
const content = await page.evaluate(async () => {
|
const content = await page.evaluate(async () => {
|
||||||
const d = window.__godsEyeView.sceneDirector;
|
const d = window.__godsEyeView.sceneDirector;
|
||||||
const scene = d._project.scenes.find(({ title }) =>
|
const scene = d._project.scenes.find(({ title }) =>
|
||||||
|
|||||||
@@ -25,12 +25,18 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome.test.mjs'];
|
const TESTS = [
|
||||||
|
'src/firstRunExperience.test.mjs',
|
||||||
|
'src/standalone/startupChrome.test.mjs',
|
||||||
|
'src/firstRunLauncherView.test.mjs',
|
||||||
|
];
|
||||||
|
|
||||||
const FILES = {
|
const FILES = {
|
||||||
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
|
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
|
||||||
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
|
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
|
||||||
html: path.join(ROOT, 'src', 'ui', 'templates', 'welcome.html'),
|
copy: path.join(ROOT, 'src', 'firstRunCopy.js'),
|
||||||
|
surfaces: path.join(ROOT, 'src', 'exclusiveSurfaces.js'),
|
||||||
|
html: path.join(ROOT, 'src', 'ui', 'views', 'WelcomeView.vue'),
|
||||||
css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'),
|
css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'),
|
||||||
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
|
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
|
||||||
voiceDescriptions: path.join(
|
voiceDescriptions: path.join(
|
||||||
@@ -53,7 +59,8 @@ const MUTATIONS = [
|
|||||||
to: "writeStored('local', sessionStorageRef, FIRST_RUN_STORAGE_KEY, 'suppressed');",
|
to: "writeStored('local', sessionStorageRef, FIRST_RUN_STORAGE_KEY, 'suppressed');",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the checkbox is ignored, so nothing can ever suppress the launcher',
|
defect:
|
||||||
|
'the checkbox is ignored, so nothing can ever suppress the launcher',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: " ? writeStored('local', storage, FIRST_RUN_STORAGE_KEY, 'suppressed')",
|
from: " ? writeStored('local', storage, FIRST_RUN_STORAGE_KEY, 'suppressed')",
|
||||||
to: ' ? false',
|
to: ' ? false',
|
||||||
@@ -77,7 +84,8 @@ const MUTATIONS = [
|
|||||||
to: ' // durable check removed',
|
to: ' // durable check removed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it',
|
defect:
|
||||||
|
'?welcome=1 no longer outranks suppression, so demos cannot replay it',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: "if (params.get('welcome') === '1') return true;",
|
from: "if (params.get('welcome') === '1') return true;",
|
||||||
to: '// replay hatch removed',
|
to: '// replay hatch removed',
|
||||||
@@ -139,8 +147,9 @@ const MUTATIONS = [
|
|||||||
to: ' /* observer never attached */',
|
to: ' /* observer never attached */',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
|
defect:
|
||||||
file: 'module',
|
'cockpit drops off the exclusive list and can stack with the launcher',
|
||||||
|
file: 'surfaces',
|
||||||
from: "export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([\n 'cockpit-mode',",
|
from: "export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([\n 'cockpit-mode',",
|
||||||
to: 'export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([',
|
to: 'export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([',
|
||||||
},
|
},
|
||||||
@@ -157,45 +166,52 @@ const MUTATIONS = [
|
|||||||
to: 'dismiss();',
|
to: 'dismiss();',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
|
defect:
|
||||||
|
'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: ' root.getClientRects().length > 0 &&\n !coveredByOverlay();',
|
from: ' root.getClientRects().length > 0 &&\n !coveredByOverlay();',
|
||||||
to: ' root.getClientRects().length > 0;',
|
to: ' root.getClientRects().length > 0;',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
|
defect:
|
||||||
|
'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: ' return Boolean(hit) && !root.contains(hit);',
|
from: ' return Boolean(hit) && !root.contains(hit);',
|
||||||
to: ' return !root.contains(hit);',
|
to: ' return !root.contains(hit);',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the launcher stops honouring a key another surface already claimed',
|
defect:
|
||||||
|
'the launcher stops honouring a key another surface already claimed',
|
||||||
file: 'keyboard',
|
file: 'keyboard',
|
||||||
from: ' || event.defaultPrevented',
|
from: ' || event.defaultPrevented',
|
||||||
to: ' /* belt removed */',
|
to: ' /* belt removed */',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Anchored past the call so it cannot land on the cockpit disclosure's own
|
// Anchored past the call so it lands on the Cockpit utility disclosure's
|
||||||
// stopImmediatePropagation() a few lines below and prove nothing about this.
|
// Escape claim and nowhere else.
|
||||||
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
|
defect:
|
||||||
|
'a Cockpit utility disclosure returns to stopPropagation, so later document listeners also act on its ESC',
|
||||||
file: 'radio',
|
file: 'radio',
|
||||||
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
|
from: " event.stopImmediatePropagation();\n const kind = displayOpen ? 'display' : 'radio';",
|
||||||
to: ' event.stopPropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
|
to: " event.stopPropagation();\n const kind = displayOpen ? 'display' : 'radio';",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
|
defect:
|
||||||
|
'the "no timer" decision is deleted, so the next editor re-litigates it blind',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: ' * ACCEPTED, DELIBERATELY NOT TIMED OUT: a surface class that never clears',
|
from: ' * ACCEPTED, DELIBERATELY NOT TIMED OUT: a surface class that never clears',
|
||||||
to: ' * (note removed)',
|
to: ' * (note removed)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'a bounded reveal timer punches the card through a recording in progress',
|
defect:
|
||||||
|
'a bounded reveal timer punches the card through a recording in progress',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;',
|
from: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;',
|
||||||
to: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;\n globalThis.setTimeout?.(reveal, 45000);',
|
to: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;\n globalThis.setTimeout?.(reveal, 45000);',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the accepted no-show vanishes from CURRENT-STATE and reads as a bug',
|
defect:
|
||||||
|
'the accepted no-show vanishes from CURRENT-STATE and reads as a bug',
|
||||||
file: 'docs',
|
file: 'docs',
|
||||||
from: 'a surface class that never clears means no launcher for that page',
|
from: 'a surface class that never clears means no launcher for that page',
|
||||||
to: 'the launcher always turns up eventually for that page',
|
to: 'the launcher always turns up eventually for that page',
|
||||||
@@ -203,10 +219,11 @@ const MUTATIONS = [
|
|||||||
|
|
||||||
// ── The checkbox may not promise what storage refused ─────────────────────
|
// ── The checkbox may not promise what storage refused ─────────────────────
|
||||||
{
|
{
|
||||||
defect: 'a refused write leaves the box ticked, promising a suppression nobody stored',
|
defect:
|
||||||
|
'a refused write leaves the box ticked, promising a suppression nobody stored',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: ' if (setFirstRunSuppressed(wanted, storage)) return;',
|
from: ' if (setFirstRunSuppressed(Boolean(wanted), storage)) return true;',
|
||||||
to: ' setFirstRunSuppressed(wanted, storage);\n return;',
|
to: ' setFirstRunSuppressed(Boolean(wanted), storage);\n return true;',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'a missing storage area reports the write as saved',
|
defect: 'a missing storage area reports the write as saved',
|
||||||
@@ -215,14 +232,16 @@ const MUTATIONS = [
|
|||||||
to: ' store?.setItem?.(key, value);\n return true;',
|
to: ' store?.setItem?.(key, value);\n return true;',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the scroll fade promises more list on a card where everything fits',
|
defect:
|
||||||
|
'the scroll fade promises more list on a card where everything fits',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: 'choiceList.dataset.scrollable = String(overflows);',
|
from: 'present({ scrollable: String(overflows) });',
|
||||||
to: "choiceList.dataset.scrollable = 'true'; void overflows;",
|
to: "present({ scrollable: 'true' }); void overflows;",
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
defect: 'the DISPLAY rail opens on first run again, stealing the impression',
|
defect:
|
||||||
|
'the DISPLAY rail opens on first run again, stealing the impression',
|
||||||
file: 'ui',
|
file: 'ui',
|
||||||
from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;",
|
from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;",
|
||||||
to: "if (panelId === 'pp-toggles' && stored === null) collapsed = false;",
|
to: "if (panelId === 'pp-toggles' && stored === null) collapsed = false;",
|
||||||
@@ -236,13 +255,15 @@ const MUTATIONS = [
|
|||||||
|
|
||||||
// ── Mission definitions ────────────────────────────────────────────────────
|
// ── Mission definitions ────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
|
defect:
|
||||||
|
'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: ' environmental: Object.freeze({',
|
from: ' environmental: Object.freeze({',
|
||||||
to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({",
|
to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key',
|
defect:
|
||||||
|
'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),",
|
from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),",
|
||||||
to: "layerIds: Object.freeze(['local-firms']),",
|
to: "layerIds: Object.freeze(['local-firms']),",
|
||||||
@@ -256,11 +277,11 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'the tile stops promising the fires it actually turns on',
|
defect: 'the tile stops promising the fires it actually turns on',
|
||||||
file: 'html',
|
file: 'html',
|
||||||
from: '<small>Live earthquakes and active fires, from USGS and NASA</small>',
|
from: " 'Live earthquakes and active fires, from USGS and NASA',",
|
||||||
to: '<small>Live earthquakes worldwide, straight from USGS</small>',
|
to: " 'Live earthquakes worldwide, straight from USGS',",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: "the owner-authored first-run line is quietly rewritten",
|
defect: 'the owner-authored first-run line is quietly rewritten',
|
||||||
file: 'html',
|
file: 'html',
|
||||||
from: 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
|
from: 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
|
||||||
to: "It feels like a forbidden cockpit. It isn't — every feed is public, and every contact is live.",
|
to: "It feels like a forbidden cockpit. It isn't — every feed is public, and every contact is live.",
|
||||||
@@ -302,21 +323,24 @@ const MUTATIONS = [
|
|||||||
to: 'if (!mission) return { ok: true, choice };',
|
to: 'if (!mission) return { ok: true, choice };',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the fires/quakes tile name stops being switchable from one constant',
|
defect:
|
||||||
file: 'module',
|
'the fires/quakes tile name stops being switchable from one constant',
|
||||||
from: 'return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
|
file: 'copy',
|
||||||
to: 'return ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
|
from: 'ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;',
|
||||||
|
to: 'ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Defaults interplay (the ruling this whole integration turns on) ────────
|
// ── Defaults interplay (the ruling this whole integration turns on) ────────
|
||||||
{
|
{
|
||||||
defect: "mission layer enables stop persisting, unlike the clicks they stand for",
|
defect:
|
||||||
|
'mission layer enables stop persisting, unlike the clicks they stand for',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: "dataManager.setEnabled(layerId, true, { origin: 'user' })",
|
from: "dataManager.setEnabled(layerId, true, { origin: 'user' })",
|
||||||
to: 'dataManager.setEnabled(layerId, true)',
|
to: 'dataManager.setEnabled(layerId, true)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'a mission hand-edits detection and kills the style auto-preset contract',
|
defect:
|
||||||
|
'a mission hand-edits detection and kills the style auto-preset contract',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: ' flyToGlobe: () => styleManager.resetToGlobeView(),',
|
from: ' flyToGlobe: () => styleManager.resetToGlobeView(),',
|
||||||
to: ' flyToGlobe: () => { styleManager._detectionUserOverridden = true; return styleManager.resetToGlobeView(); },',
|
to: ' flyToGlobe: () => { styleManager._detectionUserOverridden = true; return styleManager.resetToGlobeView(); },',
|
||||||
@@ -328,13 +352,15 @@ const MUTATIONS = [
|
|||||||
to: ' styleManager._setModels3dEnabled?.(true);\n const result = await styleManager.setContextMode(mode);',
|
to: ' styleManager._setModels3dEnabled?.(true);\n const result = await styleManager.setContextMode(mode);',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the Context panel is left collapsed, hiding the mode that just started',
|
defect:
|
||||||
|
'the Context panel is left collapsed, hiding the mode that just started',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: " styleManager.setPanelCollapsed?.('global-context-panel', false, {\n explicit: true,\n });",
|
from: " styleManager.setPanelCollapsed?.('global-context-panel', false, {\n explicit: true,\n });",
|
||||||
to: ' // panel reveal removed',
|
to: ' // panel reveal removed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the decision table is deleted, so the next editor re-litigates it blind',
|
defect:
|
||||||
|
'the decision table is deleted, so the next editor re-litigates it blind',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: ' * MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST',
|
from: ' * MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST',
|
||||||
to: ' * (notes removed)',
|
to: ' * (notes removed)',
|
||||||
@@ -342,10 +368,11 @@ const MUTATIONS = [
|
|||||||
|
|
||||||
// ── Accessibility and the transplant defects ──────────────────────────────
|
// ── Accessibility and the transplant defects ──────────────────────────────
|
||||||
{
|
{
|
||||||
defect: 'disabling the focused tile drops the keyboard to <body> mid-flight',
|
defect:
|
||||||
file: 'module',
|
'disabling the focused tile drops the keyboard to <body> mid-flight',
|
||||||
from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));",
|
file: 'html',
|
||||||
to: ' for (const button of buttons) button.disabled = next;',
|
from: ':aria-disabled="launcher?.busy ?? undefined" @click="controls?.choose(\'contacts\')"',
|
||||||
|
to: ':disabled="launcher?.busy === \'true\'" @click="controls?.choose(\'contacts\')"',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'Tab escapes into an app the visitor has not seen yet',
|
defect: 'Tab escapes into an app the visitor has not seen yet',
|
||||||
@@ -384,19 +411,22 @@ const MUTATIONS = [
|
|||||||
to: 'body.never-matches-anything #first-run-launcher,',
|
to: 'body.never-matches-anything #first-run-launcher,',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the flex card defeats [hidden] and sits in the a11y tree pre-reveal',
|
defect:
|
||||||
|
'the flex card defeats [hidden] and sits in the a11y tree pre-reveal',
|
||||||
file: 'css',
|
file: 'css',
|
||||||
from: '#first-run-launcher[hidden] {\n display: none;\n}',
|
from: '#first-run-launcher[hidden] {\n display: none;\n}',
|
||||||
to: '/* [hidden] rule removed */',
|
to: '/* [hidden] rule removed */',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the card loses its viewport cap and clips on a short landscape phone',
|
defect:
|
||||||
|
'the card loses its viewport cap and clips on a short landscape phone',
|
||||||
file: 'css',
|
file: 'css',
|
||||||
from: ' max-height: calc(100dvh - 1.5rem);',
|
from: ' max-height: calc(100dvh - 1.5rem);',
|
||||||
to: ' /* cap removed */',
|
to: ' /* cap removed */',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the mission list stops scrolling, so tiles overflow the capped card',
|
defect:
|
||||||
|
'the mission list stops scrolling, so tiles overflow the capped card',
|
||||||
file: 'css',
|
file: 'css',
|
||||||
from: ' min-height: 0;\n overflow-y: auto;',
|
from: ' min-height: 0;\n overflow-y: auto;',
|
||||||
to: ' /* scroll removed */',
|
to: ' /* scroll removed */',
|
||||||
@@ -415,19 +445,19 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'a mission tile is dropped from the menu',
|
defect: 'a mission tile is dropped from the menu',
|
||||||
file: 'html',
|
file: 'html',
|
||||||
from: ' <button type="button" data-first-run-choice="space-missions">',
|
from: ' <button type="button" data-first-run-choice="space-missions"',
|
||||||
to: ' <button type="button" data-first-run-choice-disabled="space-missions">',
|
to: ' <button type="button" data-first-run-choice-disabled="space-missions"',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the menu order stops matching the owner\'s',
|
defect: "the menu order stops matching the owner's",
|
||||||
file: 'html',
|
file: 'html',
|
||||||
from: '<button type="button" data-first-run-choice="environmental">',
|
from: '<button type="button" data-first-run-choice="environmental"',
|
||||||
to: '<button type="button" data-first-run-choice="zzz-environmental">',
|
to: '<button type="button" data-first-run-choice="zzz-environmental"',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the "don\'t show again" checkbox is removed',
|
defect: 'the "don\'t show again" checkbox is removed',
|
||||||
file: 'html',
|
file: 'html',
|
||||||
from: '<input type="checkbox" data-first-run-suppress />',
|
from: '<input type="checkbox" data-first-run-suppress @change="onSuppress" />',
|
||||||
to: '<span data-first-run-suppress-removed></span>',
|
to: '<span data-first-run-suppress-removed></span>',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -439,8 +469,8 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'the launcher is no longer announced as a dialog',
|
defect: 'the launcher is no longer announced as a dialog',
|
||||||
file: 'html',
|
file: 'html',
|
||||||
from: '<aside id="first-run-launcher" role="dialog"',
|
from: 'id="first-run-launcher" role="dialog"',
|
||||||
to: '<aside id="first-run-launcher"',
|
to: 'id="first-run-launcher"',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the launcher is revealed before the loading cover yields',
|
defect: 'the launcher is revealed before the loading cover yields',
|
||||||
@@ -449,7 +479,8 @@ const MUTATIONS = [
|
|||||||
to: " revealFirstRun();\n loadingScreen.classList.add('hidden');",
|
to: " revealFirstRun();\n loadingScreen.classList.add('hidden');",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the globe missions lose their DataManager and cannot enable layers',
|
defect:
|
||||||
|
'the globe missions lose their DataManager and cannot enable layers',
|
||||||
file: 'main',
|
file: 'main',
|
||||||
from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });',
|
from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });',
|
||||||
to: 'firstRun = initializeWelcome?.({ styleManager });',
|
to: 'firstRun = initializeWelcome?.({ styleManager });',
|
||||||
@@ -463,7 +494,8 @@ const MUTATIONS = [
|
|||||||
to: "'Counts, lists, superlatives, and attribute filters over records loaded by ENABLED layers",
|
to: "'Counts, lists, superlatives, and attribute filters over records loaded by ENABLED layers",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
|
defect:
|
||||||
|
'the instruction mapping is dropped, so voice cannot reach the modes',
|
||||||
file: 'voiceInstructions',
|
file: 'voiceInstructions',
|
||||||
from: " 'NAMED VIEWS are shorthand",
|
from: " 'NAMED VIEWS are shorthand",
|
||||||
to: " // 'NAMED VIEWS are shorthand",
|
to: " // 'NAMED VIEWS are shorthand",
|
||||||
@@ -471,21 +503,25 @@ const MUTATIONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const originals = new Map();
|
const originals = new Map();
|
||||||
for (const [key, file] of Object.entries(FILES)) originals.set(key, fs.readFileSync(file, 'utf8'));
|
for (const [key, file] of Object.entries(FILES))
|
||||||
|
originals.set(key, fs.readFileSync(file, 'utf8'));
|
||||||
|
|
||||||
/** Write only on a real change: a no-op write still wakes every file watcher. */
|
/** Write only on a real change: a no-op write still wakes every file watcher. */
|
||||||
const write = (file, next) => {
|
const write = (file, next) => {
|
||||||
if (fs.readFileSync(file, 'utf8') !== next) fs.writeFileSync(file, next);
|
if (fs.readFileSync(file, 'utf8') !== next) fs.writeFileSync(file, next);
|
||||||
};
|
};
|
||||||
const restoreAll = () => {
|
const restoreAll = () => {
|
||||||
for (const [key, file] of Object.entries(FILES)) write(file, originals.get(key));
|
for (const [key, file] of Object.entries(FILES))
|
||||||
|
write(file, originals.get(key));
|
||||||
};
|
};
|
||||||
process.on('exit', restoreAll);
|
process.on('exit', restoreAll);
|
||||||
|
|
||||||
let caught = 0;
|
let caught = 0;
|
||||||
const missed = [];
|
const missed = [];
|
||||||
|
|
||||||
console.log(`\nFirst-run launcher pin strength — ${MUTATIONS.length} individual reverts\n`);
|
console.log(
|
||||||
|
`\nFirst-run launcher pin strength — ${MUTATIONS.length} individual reverts\n`,
|
||||||
|
);
|
||||||
for (const { defect, file, from, to } of MUTATIONS) {
|
for (const { defect, file, from, to } of MUTATIONS) {
|
||||||
const original = originals.get(file);
|
const original = originals.get(file);
|
||||||
if (!original.includes(from)) {
|
if (!original.includes(from)) {
|
||||||
@@ -497,10 +533,15 @@ for (const { defect, file, from, to } of MUTATIONS) {
|
|||||||
let red = false;
|
let red = false;
|
||||||
let by = '';
|
let by = '';
|
||||||
try {
|
try {
|
||||||
execFileSync('node', ['--test', ...TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
|
execFileSync(
|
||||||
|
'node',
|
||||||
|
['--import=./build/registerVue.js', '--test', ...TESTS],
|
||||||
|
{ cwd: ROOT, encoding: 'utf8', stdio: 'pipe' },
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
red = true;
|
red = true;
|
||||||
const failed = String(error.stdout || '').split('\n')
|
const failed = String(error.stdout || '')
|
||||||
|
.split('\n')
|
||||||
.filter((line) => line.trim().startsWith('✖') && line.includes('('))
|
.filter((line) => line.trim().startsWith('✖') && line.includes('('))
|
||||||
.map((line) => line.trim().slice(2).split(' (')[0]);
|
.map((line) => line.trim().slice(2).split(' (')[0]);
|
||||||
by = [...new Set(failed)].slice(0, 2).join('; ');
|
by = [...new Set(failed)].slice(0, 2).join('; ');
|
||||||
|
|||||||
@@ -357,53 +357,6 @@ async function runArbitrationSection(page, { shots, consoleErrors }) {
|
|||||||
`classVisible=${afterUncoveredEsc.classVisible} session=${afterUncoveredEsc.session}`,
|
`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 ──
|
// ── 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
|
// 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
|
// launcher's reveal (~T+1.9s), so init genuinely sees a screen it does not
|
||||||
|
|||||||
@@ -87,17 +87,21 @@ try {
|
|||||||
row().querySelector('.data-toggle-btn').dataset.feedState === 'stale',
|
row().querySelector('.data-toggle-btn').dataset.feedState === 'stale',
|
||||||
]);
|
]);
|
||||||
const ui = window.__godsEyeView.styleManager;
|
const ui = window.__godsEyeView.styleManager;
|
||||||
ui._clearSelectedLayersBtn.click();
|
const clearButton = document.getElementById('clear-selected-layers');
|
||||||
|
clearButton.click();
|
||||||
|
// The bar renders the busy state on Vue's next flush.
|
||||||
|
await Promise.resolve();
|
||||||
result.push([
|
result.push([
|
||||||
'native clear activation presents busy state',
|
'native clear activation presents busy state',
|
||||||
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'true' &&
|
clearButton.getAttribute('aria-busy') === 'true' &&
|
||||||
!!ui._contextControls._clearSelectedLayersPromise,
|
!!ui._contextControls._clearSelectedLayersPromise,
|
||||||
]);
|
]);
|
||||||
await ui._contextControls._clearSelectedLayersPromise;
|
await ui._contextControls._clearSelectedLayersPromise;
|
||||||
|
await Promise.resolve();
|
||||||
result.push([
|
result.push([
|
||||||
'clear settles through the existing layer transaction',
|
'clear settles through the existing layer transaction',
|
||||||
!manager.isEnabled(id) &&
|
!manager.isEnabled(id) &&
|
||||||
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'false',
|
clearButton.getAttribute('aria-busy') === 'false',
|
||||||
]);
|
]);
|
||||||
await manager.setEnabled(id, true);
|
await manager.setEnabled(id, true);
|
||||||
const final = row().querySelector('.data-toggle-btn');
|
const final = row().querySelector('.data-toggle-btn');
|
||||||
|
|||||||
@@ -62,13 +62,17 @@ try {
|
|||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'orbit controls and indicator agree',
|
'orbit controls and indicator agree',
|
||||||
await page.evaluate(() => {
|
await page.evaluate(async () => {
|
||||||
const ui = window.__godsEyeView.styleManager;
|
const ui = window.__godsEyeView.styleManager;
|
||||||
|
// Vue renders on a microtask; a task later the indicator shows it.
|
||||||
|
const rendered = () => new Promise((resolve) => setTimeout(resolve));
|
||||||
ui.setOrbit(true);
|
ui.setOrbit(true);
|
||||||
|
await rendered();
|
||||||
const active =
|
const active =
|
||||||
ui.orbitController.active &&
|
ui.orbitController.active &&
|
||||||
document.getElementById('orbit-indicator').classList.contains('active');
|
document.getElementById('orbit-indicator').classList.contains('active');
|
||||||
ui.setOrbit(false);
|
ui.setOrbit(false);
|
||||||
|
await rendered();
|
||||||
return (
|
return (
|
||||||
active &&
|
active &&
|
||||||
!ui.orbitController.active &&
|
!ui.orbitController.active &&
|
||||||
@@ -120,8 +124,11 @@ try {
|
|||||||
const ui = window.__godsEyeView.styleManager;
|
const ui = window.__godsEyeView.styleManager;
|
||||||
return (
|
return (
|
||||||
ui._searchedLocationLabel === '39.7392° N, 104.9903° W' &&
|
ui._searchedLocationLabel === '39.7392° N, 104.9903° W' &&
|
||||||
ui._locationMiniCity.textContent.includes('39.7392° N') &&
|
document
|
||||||
ui._locationMiniPoi.textContent === '104.9903° W'
|
.getElementById('location-mini-city')
|
||||||
|
.textContent.includes('39.7392° N') &&
|
||||||
|
document.getElementById('location-mini-poi').textContent ===
|
||||||
|
'104.9903° W'
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -194,8 +201,11 @@ try {
|
|||||||
const ui = window.__godsEyeView.styleManager;
|
const ui = window.__godsEyeView.styleManager;
|
||||||
return (
|
return (
|
||||||
ui._searchedLocationLabel === 'Second landmark, Test city' &&
|
ui._searchedLocationLabel === 'Second landmark, Test city' &&
|
||||||
ui._locationMiniCity.textContent.includes('Second landmark') &&
|
document
|
||||||
ui._locationMiniPoi.textContent === 'Test city' &&
|
.getElementById('location-mini-city')
|
||||||
|
.textContent.includes('Second landmark') &&
|
||||||
|
document.getElementById('location-mini-poi').textContent ===
|
||||||
|
'Test city' &&
|
||||||
ui.activeStyle === 'normal' &&
|
ui.activeStyle === 'normal' &&
|
||||||
!ui._locationSearch.classList.contains('searching')
|
!ui._locationSearch.classList.contains('searching')
|
||||||
);
|
);
|
||||||
@@ -227,8 +237,10 @@ try {
|
|||||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
!ui._poiRow.classList.contains('expanded') &&
|
!document.getElementById('poi-row').classList.contains('expanded') &&
|
||||||
!ui._locationBarDivider.classList.contains('visible')
|
!document
|
||||||
|
.getElementById('location-bar-divider')
|
||||||
|
.classList.contains('visible')
|
||||||
);
|
);
|
||||||
}, cityId),
|
}, cityId),
|
||||||
);
|
);
|
||||||
@@ -284,7 +296,7 @@ try {
|
|||||||
'native reset returns through the existing camera handoff',
|
'native reset returns through the existing camera handoff',
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const ui = window.__godsEyeView.styleManager;
|
const ui = window.__godsEyeView.styleManager;
|
||||||
ui._resetGlobeBtn.click();
|
document.getElementById('reset-globe-view').click();
|
||||||
const pending = ui._globeResetPromise;
|
const pending = ui._globeResetPromise;
|
||||||
if (!pending) return false;
|
if (!pending) return false;
|
||||||
await pending;
|
await pending;
|
||||||
@@ -301,8 +313,11 @@ try {
|
|||||||
ui._locationControls.destroy();
|
ui._locationControls.destroy();
|
||||||
ui._locationLookup.destroy();
|
ui._locationLookup.destroy();
|
||||||
const before = ui._navigationGeneration;
|
const before = ui._navigationGeneration;
|
||||||
ui._locationPills.querySelector('button').click();
|
document
|
||||||
ui._resetGlobeBtn.click();
|
.getElementById('location-pills')
|
||||||
|
.querySelector('button')
|
||||||
|
?.click();
|
||||||
|
document.getElementById('reset-globe-view').click();
|
||||||
ui._locationSearch.dispatchEvent(
|
ui._locationSearch.dispatchEvent(
|
||||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -69,9 +69,10 @@ try {
|
|||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
return (
|
return (
|
||||||
manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
|
document
|
||||||
.stackId === 'osm' &&
|
.getElementById('map-stack-chips')
|
||||||
manager._mapStackStatus.textContent ===
|
.querySelector('[aria-pressed="true"]')?.dataset.stackId === 'osm' &&
|
||||||
|
document.getElementById('map-stack-status').textContent ===
|
||||||
manager.mapStackController.getState().activeStack.shortLabel
|
manager.mapStackController.getState().activeStack.shortLabel
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -81,32 +82,38 @@ try {
|
|||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
const result = await manager.setMapStack('esri-imagery');
|
const result = await manager.setMapStack('esri-imagery');
|
||||||
|
// The dock renders the published row on Vue's next flush.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
const active = manager.mapStackController.getActiveId();
|
const active = manager.mapStackController.getActiveId();
|
||||||
return (
|
return (
|
||||||
window.__qaMapSelections.at(-1) === 'esri-imagery' &&
|
window.__qaMapSelections.at(-1) === 'esri-imagery' &&
|
||||||
manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
|
document
|
||||||
.stackId === active &&
|
.getElementById('map-stack-chips')
|
||||||
|
.querySelector('[aria-pressed="true"]')?.dataset.stackId === active &&
|
||||||
(result.ok || !!manager.mapStackController.getState().lastError)
|
(result.ok || !!manager.mapStackController.getState().lastError)
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'refresh revokes retained old chips',
|
'refresh re-presents the chips without duplicating them',
|
||||||
await page.evaluate(() => {
|
await page.evaluate(async () => {
|
||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
const old = manager._mapStackChips.querySelector('[data-stack-id="osm"]');
|
|
||||||
manager._mapSourceControls.refresh();
|
manager._mapSourceControls.refresh();
|
||||||
const before = window.__qaMapSelections.length;
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
old.click();
|
return (
|
||||||
return window.__qaMapSelections.length === before;
|
document.querySelectorAll('#map-stack-chips [data-stack-id="osm"]')
|
||||||
|
.length === 1
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'refreshed chips still select once',
|
'refreshed chips still select once',
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const manager = window.__godsEyeView.styleManager;
|
|
||||||
const before = window.__qaMapSelections.length;
|
const before = window.__qaMapSelections.length;
|
||||||
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
|
document
|
||||||
|
.getElementById('map-stack-chips')
|
||||||
|
.querySelector('[data-stack-id="osm"]')
|
||||||
|
.click();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
return window.__qaMapSelections.length === before + 1;
|
return window.__qaMapSelections.length === before + 1;
|
||||||
}),
|
}),
|
||||||
@@ -188,7 +195,10 @@ try {
|
|||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
const before = window.__qaMapSelections.length;
|
const before = window.__qaMapSelections.length;
|
||||||
manager._mapSourceControls.destroy();
|
manager._mapSourceControls.destroy();
|
||||||
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
|
document
|
||||||
|
.getElementById('map-stack-chips')
|
||||||
|
.querySelector('[data-stack-id="osm"]')
|
||||||
|
.click();
|
||||||
const result = await manager._mapSourceControls.select('esri-imagery');
|
const result = await manager._mapSourceControls.select('esri-imagery');
|
||||||
return result === null && window.__qaMapSelections.length === before;
|
return result === null && window.__qaMapSelections.length === before;
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -200,8 +200,8 @@ const checkResponsiveStyleFocus = async (width) => {
|
|||||||
await page.focus('#control-panel-toggle');
|
await page.focus('#control-panel-toggle');
|
||||||
await pressTabs(2); // disclosure -> pin -> Normal
|
await pressTabs(2); // disclosure -> pin -> Normal
|
||||||
}
|
}
|
||||||
for (const [index, style] of ['normal', 'thermal', 'snow'].entries()) {
|
for (const [tabs, style] of [[0, 'normal'], [3, 'thermal'], [2, 'noir']]) {
|
||||||
if (index) await pressTabs(3);
|
if (tabs) await pressTabs(tabs);
|
||||||
const state = await styleFocusMetrics();
|
const state = await styleFocusMetrics();
|
||||||
check(`${width} px ${style} keyboard ring is visible and unclipped`,
|
check(`${width} px ${style} keyboard ring is visible and unclipped`,
|
||||||
hasVisibleStyleFocus(state, style) && state.selectedStyle === 'normal'
|
hasVisibleStyleFocus(state, style) && state.selectedStyle === 'normal'
|
||||||
@@ -567,13 +567,13 @@ try {
|
|||||||
// Visual Styles precede the five Map Source tiles. Keep the existing opening
|
// 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.
|
// handoff to selected OSM, then use the user's real Shift+Tab path into styles.
|
||||||
await pressTabs(5, true);
|
await pressTabs(5, true);
|
||||||
const snowFocus = await styleFocusMetrics();
|
const noirFocus = await styleFocusMetrics();
|
||||||
check('Shift+Tab reaches Snow with a visible ring while Normal and OSM stay selected',
|
check('Shift+Tab reaches Noir with a visible ring while Normal and OSM stay selected',
|
||||||
hasVisibleStyleFocus(snowFocus, 'snow') && !snowFocus.selected
|
hasVisibleStyleFocus(noirFocus, 'noir') && !noirFocus.selected
|
||||||
&& snowFocus.selectedStyle === 'normal' && snowFocus.selectedMap === 'osm',
|
&& noirFocus.selectedStyle === 'normal' && noirFocus.selectedMap === 'osm',
|
||||||
JSON.stringify(snowFocus));
|
JSON.stringify(noirFocus));
|
||||||
await page.screenshot({ path: path.join(shotsDir, 'keyboard-style-focus.png') });
|
await page.screenshot({ path: path.join(shotsDir, 'keyboard-style-focus.png') });
|
||||||
await pressTabs(3, true);
|
await pressTabs(2, true);
|
||||||
const middleFocus = await styleFocusMetrics();
|
const middleFocus = await styleFocusMetrics();
|
||||||
check('Shift+Tab traverses the middle FLIR style without activating it',
|
check('Shift+Tab traverses the middle FLIR style without activating it',
|
||||||
hasVisibleStyleFocus(middleFocus, 'thermal') && !middleFocus.selected
|
hasVisibleStyleFocus(middleFocus, 'thermal') && !middleFocus.selected
|
||||||
@@ -871,6 +871,9 @@ try {
|
|||||||
return originalSetStack(stackId);
|
return originalSetStack(stackId);
|
||||||
};
|
};
|
||||||
const switchPromise = styleManager._setMapStack('osm', { syncShare: false });
|
const switchPromise = styleManager._setMapStack('osm', { syncShare: false });
|
||||||
|
// The dock renders the published row on Vue's next flush.
|
||||||
|
const rendered = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
await rendered();
|
||||||
const during = {
|
const during = {
|
||||||
status: document.getElementById('map-stack-status').textContent,
|
status: document.getElementById('map-stack-status').textContent,
|
||||||
active: [...document.querySelectorAll('.map-stack-chip')]
|
active: [...document.querySelectorAll('.map-stack-chip')]
|
||||||
@@ -879,6 +882,7 @@ try {
|
|||||||
};
|
};
|
||||||
release();
|
release();
|
||||||
await switchPromise;
|
await switchPromise;
|
||||||
|
await rendered();
|
||||||
const after = {
|
const after = {
|
||||||
status: document.getElementById('map-stack-status').textContent,
|
status: document.getElementById('map-stack-status').textContent,
|
||||||
active: [...document.querySelectorAll('.map-stack-chip')]
|
active: [...document.querySelectorAll('.map-stack-chip')]
|
||||||
|
|||||||
+1
-1
@@ -310,7 +310,7 @@ try {
|
|||||||
countFrames(2_500),
|
countFrames(2_500),
|
||||||
page.evaluate(() => new Promise((resolve) => setTimeout(() => {
|
page.evaluate(() => new Promise((resolve) => setTimeout(() => {
|
||||||
// Drive the actual sharpen slider: input event → handler →
|
// Drive the actual sharpen slider: input event → handler →
|
||||||
// _applySharpenIntensity → governorRequestRender. Proves the wiring,
|
// _setSharpenIntensity → governorRequestRender. Proves the wiring,
|
||||||
// not just the facade.
|
// not just the facade.
|
||||||
const slider = document.getElementById('sharpen-intensity-slider');
|
const slider = document.getElementById('sharpen-intensity-slider');
|
||||||
if (!slider) { resolve({ ok: false }); return; }
|
if (!slider) { resolve({ ok: false }); return; }
|
||||||
|
|||||||
+120
-342
@@ -81,6 +81,32 @@ function check(name, ok, detail = '') {
|
|||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
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,
|
// Native pick buffers and clustered overlay entries settle on rendered frames,
|
||||||
// not on wall-clock sleeps, particularly with software rendering.
|
// not on wall-clock sleeps, particularly with software rendering.
|
||||||
async function settleRadioFrames(page) {
|
async function settleRadioFrames(page) {
|
||||||
@@ -285,31 +311,16 @@ async function main() {
|
|||||||
window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true);
|
window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const enablePanels = await revealRadioControls(page, 'radio-enable-btn');
|
||||||
const enableStartedAt = Date.now();
|
const enableStartedAt = Date.now();
|
||||||
const initialDisclosure = await page.evaluate(() => {
|
await page.click('#radio-enable-btn');
|
||||||
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.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
|
const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
|
||||||
return state.enabled && !state.loading && state.stationCount === 750;
|
return state.enabled && !state.loading && state.stationCount === 750;
|
||||||
});
|
});
|
||||||
const enableElapsedMs = Date.now() - enableStartedAt;
|
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 initial = await page.evaluate(() => {
|
||||||
const module = window.__godsEyeView.dataManager.layers.get('radio').module;
|
const module = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||||
const state = module.getUIState();
|
const state = module.getUIState();
|
||||||
@@ -328,9 +339,6 @@ async function main() {
|
|||||||
color: entry.dataset.radioColor,
|
color: entry.dataset.radioColor,
|
||||||
})),
|
})),
|
||||||
enablePressed: document.getElementById('radio-enable-btn').getAttribute('aria-pressed'),
|
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,
|
fullPlayEnabled: !document.getElementById('radio-play-btn').disabled,
|
||||||
fullPlayLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'),
|
fullPlayLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'),
|
||||||
tunerVisible: !document.getElementById('radio-tuner').hidden,
|
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('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('enable does not autoplay', initial.plays === 0 && initial.state.audioState === 'stopped');
|
||||||
check(
|
check(
|
||||||
'compact and detailed Radio power controls reflect the layer manager independently of disclosure',
|
'Radio power control reflects the layer manager after the panels collapse again',
|
||||||
initial.enablePressed === 'true' && initial.compactEnablePressed === 'true'
|
initial.enablePressed === 'true',
|
||||||
&& initial.launcherExpanded === 'true' && initial.launcherActive,
|
|
||||||
);
|
);
|
||||||
check('dynamic canonical and genre categories reach the UI', initial.filterOptions >= 10, `${initial.filterOptions} options`);
|
check('dynamic canonical and genre categories reach the UI', initial.filterOptions >= 10, `${initial.filterOptions} options`);
|
||||||
const musicOption = initial.filterPalette.find((entry) => entry.id === 'music');
|
const musicOption = initial.filterPalette.find((entry) => entry.id === 'music');
|
||||||
@@ -419,14 +426,10 @@ async function main() {
|
|||||||
prevDisabled: document.getElementById('radio-prev-btn').disabled,
|
prevDisabled: document.getElementById('radio-prev-btn').disabled,
|
||||||
playDisabled: document.getElementById('radio-play-btn').disabled,
|
playDisabled: document.getElementById('radio-play-btn').disabled,
|
||||||
volumeDisabled: document.getElementById('radio-volume').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'),
|
fullLabel: document.getElementById('radio-enable-btn').getAttribute('aria-label'),
|
||||||
fullText: document.getElementById('radio-enable-btn').textContent,
|
fullText: document.getElementById('radio-enable-btn').textContent,
|
||||||
layerState: document.getElementById('radio-layer-state').textContent,
|
layerState: document.getElementById('radio-layer-state').textContent,
|
||||||
playbackMessage: document.getElementById('radio-playback-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,
|
dataRailText: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.textContent,
|
||||||
dataRailState: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.dataset.feedState,
|
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'),
|
dataRailLabel: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.getAttribute('aria-label'),
|
||||||
@@ -468,14 +471,10 @@ async function main() {
|
|||||||
&& uncertainPresentation.prevDisabled
|
&& uncertainPresentation.prevDisabled
|
||||||
&& uncertainPresentation.playDisabled
|
&& uncertainPresentation.playDisabled
|
||||||
&& uncertainPresentation.volumeDisabled
|
&& uncertainPresentation.volumeDisabled
|
||||||
&& /compact Radio controls/i.test(uncertainPresentation.disclosureLabel)
|
|
||||||
&& /uncertain/i.test(uncertainPresentation.compactPowerLabel)
|
|
||||||
&& uncertainPresentation.compactPowerText === 'RECONCILE'
|
|
||||||
&& /uncertain/i.test(uncertainPresentation.fullLabel)
|
&& /uncertain/i.test(uncertainPresentation.fullLabel)
|
||||||
&& uncertainPresentation.fullText === 'RECONCILE'
|
&& uncertainPresentation.fullText === 'RECONCILE'
|
||||||
&& uncertainPresentation.layerState === 'UNCERTAIN'
|
&& uncertainPresentation.layerState === 'UNCERTAIN'
|
||||||
&& /uncertain/i.test(uncertainPresentation.playbackMessage)
|
&& /uncertain/i.test(uncertainPresentation.playbackMessage)
|
||||||
&& uncertainPresentation.miniStation === 'RADIO STATE UNCERTAIN'
|
|
||||||
&& uncertainPresentation.dataRailText === 'UNCERTAIN'
|
&& uncertainPresentation.dataRailText === 'UNCERTAIN'
|
||||||
&& uncertainPresentation.dataRailState === 'uncertain'
|
&& uncertainPresentation.dataRailState === 'uncertain'
|
||||||
&& /uncertain/i.test(uncertainPresentation.dataRailLabel)
|
&& /uncertain/i.test(uncertainPresentation.dataRailLabel)
|
||||||
@@ -517,10 +516,6 @@ async function main() {
|
|||||||
presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive,
|
presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive,
|
||||||
markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show,
|
markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show,
|
||||||
full: document.getElementById('radio-enable-btn').textContent,
|
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,
|
layerState: document.getElementById('radio-layer-state').textContent,
|
||||||
message: document.getElementById('radio-playback-state').textContent,
|
message: document.getElementById('radio-playback-state').textContent,
|
||||||
stopDisabled: document.getElementById('radio-stop-btn').disabled,
|
stopDisabled: document.getElementById('radio-stop-btn').disabled,
|
||||||
@@ -534,8 +529,6 @@ async function main() {
|
|||||||
&& disablingPresentation.presentationActive === false
|
&& disablingPresentation.presentationActive === false
|
||||||
&& disablingPresentation.markerSourceVisible === false
|
&& disablingPresentation.markerSourceVisible === false
|
||||||
&& disablingPresentation.full === 'DISABLING'
|
&& disablingPresentation.full === 'DISABLING'
|
||||||
&& /compact Radio controls/i.test(disablingPresentation.disclosure)
|
|
||||||
&& disablingPresentation.compactPower === 'DISABLING'
|
|
||||||
&& disablingPresentation.layerState === 'DISABLING'
|
&& disablingPresentation.layerState === 'DISABLING'
|
||||||
&& /disabling/i.test(disablingPresentation.message)
|
&& /disabling/i.test(disablingPresentation.message)
|
||||||
&& disablingPresentation.stopDisabled
|
&& disablingPresentation.stopDisabled
|
||||||
@@ -592,10 +585,6 @@ async function main() {
|
|||||||
presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive,
|
presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive,
|
||||||
markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show,
|
markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show,
|
||||||
full: document.getElementById('radio-enable-btn').textContent,
|
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,
|
layerState: document.getElementById('radio-layer-state').textContent,
|
||||||
message: document.getElementById('radio-playback-state').textContent,
|
message: document.getElementById('radio-playback-state').textContent,
|
||||||
tunerHidden: document.getElementById('radio-tuner').hidden,
|
tunerHidden: document.getElementById('radio-tuner').hidden,
|
||||||
@@ -610,10 +599,6 @@ async function main() {
|
|||||||
&& enablingPresentation.presentationActive === false
|
&& enablingPresentation.presentationActive === false
|
||||||
&& enablingPresentation.markerSourceVisible === false
|
&& enablingPresentation.markerSourceVisible === false
|
||||||
&& enablingPresentation.full === 'ENABLING'
|
&& enablingPresentation.full === 'ENABLING'
|
||||||
&& enablingPresentation.disclosure === 'Go to expanded Radio section'
|
|
||||||
&& enablingPresentation.disclosureControls === 'radio-panel'
|
|
||||||
&& enablingPresentation.disclosureExpanded === 'true'
|
|
||||||
&& enablingPresentation.compactPower === 'ENABLING'
|
|
||||||
&& enablingPresentation.layerState === 'ENABLING'
|
&& enablingPresentation.layerState === 'ENABLING'
|
||||||
&& /enabling/i.test(enablingPresentation.message)
|
&& /enabling/i.test(enablingPresentation.message)
|
||||||
&& enablingPresentation.tunerHidden
|
&& enablingPresentation.tunerHidden
|
||||||
@@ -1115,7 +1100,7 @@ async function main() {
|
|||||||
document.getElementById('installations-search-btn').click();
|
document.getElementById('installations-search-btn').click();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
const installationFalseMessage = document.getElementById('toast').textContent;
|
const installationFalseMessage = document.getElementById('toast').textContent;
|
||||||
const installationFalseReleased = !document.getElementById('installations-search-btn').disabled;
|
const installationFalseReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
|
||||||
|
|
||||||
dataManager.setEnabled = async (layerId, shouldEnable) => {
|
dataManager.setEnabled = async (layerId, shouldEnable) => {
|
||||||
if (layerId === 'military-installations' && shouldEnable) {
|
if (layerId === 'military-installations' && shouldEnable) {
|
||||||
@@ -1126,7 +1111,7 @@ async function main() {
|
|||||||
document.getElementById('installations-search-btn').click();
|
document.getElementById('installations-search-btn').click();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
const installationEnableRejectionMessage = document.getElementById('toast').textContent;
|
const installationEnableRejectionMessage = document.getElementById('toast').textContent;
|
||||||
const installationEnableRejectionReleased = !document.getElementById('installations-search-btn').disabled;
|
const installationEnableRejectionReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
|
||||||
|
|
||||||
dataManager.setEnabled = async (layerId, shouldEnable) => {
|
dataManager.setEnabled = async (layerId, shouldEnable) => {
|
||||||
if (layerId === 'military-installations' && shouldEnable) enabled.add(layerId);
|
if (layerId === 'military-installations' && shouldEnable) enabled.add(layerId);
|
||||||
@@ -1138,13 +1123,13 @@ async function main() {
|
|||||||
document.getElementById('installations-search-btn').click();
|
document.getElementById('installations-search-btn').click();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
const installationRejectionMessage = document.getElementById('toast').textContent;
|
const installationRejectionMessage = document.getElementById('toast').textContent;
|
||||||
const installationRejectionReleased = !document.getElementById('installations-search-btn').disabled;
|
const installationRejectionReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
|
||||||
|
|
||||||
if (installations) installations.searchNearby = async () => false;
|
if (installations) installations.searchNearby = async () => false;
|
||||||
document.getElementById('installations-search-btn').click();
|
document.getElementById('installations-search-btn').click();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
const installationSearchFalseMessage = document.getElementById('toast').textContent;
|
const installationSearchFalseMessage = document.getElementById('toast').textContent;
|
||||||
const installationSearchFalseReleased = !document.getElementById('installations-search-btn').disabled;
|
const installationSearchFalseReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
return {
|
return {
|
||||||
semanticFalseMessage,
|
semanticFalseMessage,
|
||||||
@@ -1581,112 +1566,9 @@ async function main() {
|
|||||||
JSON.stringify(directoryRefreshContinuity),
|
JSON.stringify(directoryRefreshContinuity),
|
||||||
);
|
);
|
||||||
await page.select('#radio-filter', 'news');
|
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');
|
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 multiExpandedPanelLayout = await page.evaluate(async () => {
|
||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
const ids = ['data-panel', 'scene-panel', 'pp-toggles', 'cctv-panel', 'global-context-panel'];
|
const ids = ['data-panel', 'scene-panel', 'pp-toggles', 'cctv-panel', 'global-context-panel'];
|
||||||
@@ -1834,7 +1716,14 @@ async function main() {
|
|||||||
else panel.setAttribute('style', state.style);
|
else panel.setAttribute('style', state.style);
|
||||||
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
|
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
|
||||||
else panel.setAttribute('aria-hidden', state.ariaHidden);
|
else panel.setAttribute('aria-hidden', state.ariaHidden);
|
||||||
manager._syncPanelCollapseButton(panel);
|
// The restored classes are the panel's state again.
|
||||||
|
const has = (name) => panel.classList.contains(name);
|
||||||
|
manager._presentPanel(panel, {
|
||||||
|
collapsed: has('collapsed'),
|
||||||
|
pinned: has('dock-pinned'),
|
||||||
|
pinnedTop: has('dock-pinned-top'),
|
||||||
|
auto: has('layout-auto-collapsed') ? 'layout' : has('accordion-collapsed') ? 'accordion' : null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
for (const [id, state] of Object.entries(prior.stacks)) {
|
for (const [id, state] of Object.entries(prior.stacks)) {
|
||||||
const stack = document.getElementById(id);
|
const stack = document.getElementById(id);
|
||||||
@@ -2062,7 +1951,6 @@ async function main() {
|
|||||||
hudVariant: manager.hud.getVariant(),
|
hudVariant: manager.hud.getVariant(),
|
||||||
displayExpanded: display.getAttribute('aria-expanded') === 'true',
|
displayExpanded: display.getAttribute('aria-expanded') === 'true',
|
||||||
radioExpanded: radio.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,
|
focusId: document.activeElement?.id || null,
|
||||||
detectionMode: manager.getDetectionState().detectionMode,
|
detectionMode: manager.getDetectionState().detectionMode,
|
||||||
models3dEnabled: manager._models3dEnabled,
|
models3dEnabled: manager._models3dEnabled,
|
||||||
@@ -2093,7 +1981,7 @@ async function main() {
|
|||||||
manager._setCockpitDisplayPortalActive(true);
|
manager._setCockpitDisplayPortalActive(true);
|
||||||
hud.hidden = false;
|
hud.hidden = false;
|
||||||
signal.hidden = false;
|
signal.hidden = false;
|
||||||
dataPanel.classList.remove('collapsed');
|
manager._presentPanel(dataPanel, { collapsed: false });
|
||||||
window.__godsEyeView.styleManager._syncLeftPanelAdaptiveLayout();
|
window.__godsEyeView.styleManager._syncLeftPanelAdaptiveLayout();
|
||||||
window.__godsEyeView.styleManager.cockpitView.syncSignalLayout();
|
window.__godsEyeView.styleManager.cockpitView.syncSignalLayout();
|
||||||
if (display.getAttribute('aria-expanded') === 'true') display.click();
|
if (display.getAttribute('aria-expanded') === 'true') display.click();
|
||||||
@@ -2158,11 +2046,13 @@ async function main() {
|
|||||||
for (let index = 0; index < 5; index += 1) {
|
for (let index = 0; index < 5; index += 1) {
|
||||||
recordLayoutStep();
|
recordLayoutStep();
|
||||||
if (index < 4) {
|
if (index < 4) {
|
||||||
if (index < 2) {
|
// Off and back on in Minimal, so the toggle must restore a
|
||||||
hudLayout.value = index === 0 ? 'operator' : 'minimal';
|
// non-default layout before the cycle returns to Tactical.
|
||||||
hudLayout.dispatchEvent(new Event('change', { bubbles: true }));
|
if (index === 1 || index === 2) {
|
||||||
} else {
|
|
||||||
hudToggle.click();
|
hudToggle.click();
|
||||||
|
} else {
|
||||||
|
hudLayout.value = index === 0 ? 'minimal' : 'tactical';
|
||||||
|
hudLayout.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
}
|
}
|
||||||
await waitForHudSettle();
|
await waitForHudSettle();
|
||||||
}
|
}
|
||||||
@@ -2176,8 +2066,7 @@ async function main() {
|
|||||||
contextCard.hidden = false;
|
contextCard.hidden = false;
|
||||||
// Expand the panel the same way this block already does at entry, so the
|
// Expand the panel the same way this block already does at entry, so the
|
||||||
// portaled Display groups measured further down keep their DOM home.
|
// portaled Display groups measured further down keep their DOM home.
|
||||||
dataPanel.classList.remove('collapsed', 'layout-auto-collapsed');
|
manager._presentPanel(dataPanel, { collapsed: false, auto: null });
|
||||||
manager._syncPanelCollapseButton(dataPanel);
|
|
||||||
manager._syncLeftPanelAdaptiveLayout();
|
manager._syncLeftPanelAdaptiveLayout();
|
||||||
await waitForLayout();
|
await waitForLayout();
|
||||||
const panelRect = dataPanel.getBoundingClientRect();
|
const panelRect = dataPanel.getBoundingClientRect();
|
||||||
@@ -2271,24 +2160,27 @@ async function main() {
|
|||||||
.every((control) => control.closest('.pp-toggle-group')?.parentElement
|
.every((control) => control.closest('.pp-toggle-group')?.parentElement
|
||||||
?.closest('#cockpit-display-panel'));
|
?.closest('#cockpit-display-panel'));
|
||||||
display.click();
|
display.click();
|
||||||
|
await waitForLayout();
|
||||||
|
|
||||||
manager.cockpitView.syncSignalLayout();
|
manager.cockpitView.syncSignalLayout();
|
||||||
radio.click();
|
radio.click();
|
||||||
|
await waitForLayout();
|
||||||
const radioOpened = radio.getAttribute('aria-expanded') === 'true'
|
const radioOpened = radio.getAttribute('aria-expanded') === 'true'
|
||||||
&& !document.getElementById('cockpit-radio-panel').hidden;
|
&& !document.getElementById('cockpit-radio-panel').hidden;
|
||||||
const mutuallyExclusive = display.getAttribute('aria-expanded') === 'false';
|
const mutuallyExclusive = display.getAttribute('aria-expanded') === 'false';
|
||||||
radio.click();
|
radio.click();
|
||||||
|
await waitForLayout();
|
||||||
|
|
||||||
manager.cockpitView.syncSignalLayout();
|
manager.cockpitView.syncSignalLayout();
|
||||||
radio.click();
|
radio.click();
|
||||||
|
await waitForLayout();
|
||||||
|
|
||||||
const radioNavigationAvailable = [
|
const radioNavigationAvailable = [
|
||||||
document.getElementById('cockpit-radio-prev-btn'),
|
document.getElementById('cockpit-radio-prev-btn'),
|
||||||
document.getElementById('cockpit-radio-next-btn'),
|
document.getElementById('cockpit-radio-next-btn'),
|
||||||
].every((button) => button?.isConnected && !button.disabled);
|
].every((button) => button?.isConnected && !button.disabled);
|
||||||
const detailsExcluded = !document.querySelector(
|
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';
|
const scenesHidden = getComputedStyle(scene).display === 'none';
|
||||||
radio.click();
|
radio.click();
|
||||||
@@ -2331,7 +2223,7 @@ async function main() {
|
|||||||
scenesHidden,
|
scenesHidden,
|
||||||
mapProviderUtilityStable,
|
mapProviderUtilityStable,
|
||||||
hudInteractionsExact: layoutSteps.map((step) => step.hud).join('>')
|
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
|
// The accordion clears the topline, Cockpit cards, and Cesium credits
|
||||||
// across every HUD layout. Its list owns any overflow internally.
|
// across every HUD layout. Its list owns any overflow internally.
|
||||||
leftLaneClearAcrossHudCycle: layoutSteps.every((step) => (
|
leftLaneClearAcrossHudCycle: layoutSteps.every((step) => (
|
||||||
@@ -2381,7 +2273,6 @@ async function main() {
|
|||||||
manager.setPanelCollapsed('global-context-panel', prior.contextCollapsed);
|
manager.setPanelCollapsed('global-context-panel', prior.contextCollapsed);
|
||||||
if (display.getAttribute('aria-expanded') !== String(prior.displayExpanded)) display.click();
|
if (display.getAttribute('aria-expanded') !== String(prior.displayExpanded)) display.click();
|
||||||
if (radio.getAttribute('aria-expanded') !== String(prior.radioExpanded)) radio.click();
|
if (radio.getAttribute('aria-expanded') !== String(prior.radioExpanded)) radio.click();
|
||||||
manager._setRadioDisclosure(prior.contextRadioExpanded);
|
|
||||||
hud.hidden = prior.hudHidden;
|
hud.hidden = prior.hudHidden;
|
||||||
signal.hidden = prior.signalHidden;
|
signal.hidden = prior.signalHidden;
|
||||||
if (prior.utilityTop) hud.style.setProperty('--cockpit-utility-top', prior.utilityTop);
|
if (prior.utilityTop) hud.style.setProperty('--cockpit-utility-top', prior.utilityTop);
|
||||||
@@ -2402,7 +2293,14 @@ async function main() {
|
|||||||
else panel.setAttribute('style', state.style);
|
else panel.setAttribute('style', state.style);
|
||||||
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
|
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
|
||||||
else panel.setAttribute('aria-hidden', state.ariaHidden);
|
else panel.setAttribute('aria-hidden', state.ariaHidden);
|
||||||
manager._syncPanelCollapseButton(panel);
|
// The restored classes are the panel's state again.
|
||||||
|
const has = (name) => panel.classList.contains(name);
|
||||||
|
manager._presentPanel(panel, {
|
||||||
|
collapsed: has('collapsed'),
|
||||||
|
pinned: has('dock-pinned'),
|
||||||
|
pinnedTop: has('dock-pinned-top'),
|
||||||
|
auto: has('layout-auto-collapsed') ? 'layout' : has('accordion-collapsed') ? 'accordion' : null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId;
|
manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId;
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@@ -2416,7 +2314,6 @@ async function main() {
|
|||||||
&& document.getElementById('global-context-panel').classList.contains('collapsed') === prior.contextCollapsed
|
&& document.getElementById('global-context-panel').classList.contains('collapsed') === prior.contextCollapsed
|
||||||
&& display.getAttribute('aria-expanded') === String(prior.displayExpanded)
|
&& display.getAttribute('aria-expanded') === String(prior.displayExpanded)
|
||||||
&& radio.getAttribute('aria-expanded') === String(prior.radioExpanded)
|
&& radio.getAttribute('aria-expanded') === String(prior.radioExpanded)
|
||||||
&& document.getElementById('context-radio-toggle-btn')?.getAttribute('aria-expanded') === String(prior.contextRadioExpanded)
|
|
||||||
&& hud.hidden === prior.hudHidden
|
&& hud.hidden === prior.hudHidden
|
||||||
&& signal.hidden === prior.signalHidden
|
&& signal.hidden === prior.signalHidden
|
||||||
&& intelHud.style.getPropertyValue('transition') === priorHudTransition
|
&& intelHud.style.getPropertyValue('transition') === priorHudTransition
|
||||||
@@ -2472,21 +2369,20 @@ async function main() {
|
|||||||
check('first Play scenario starts without a leaked Radio selection', firstPlayPrecondition === null,
|
check('first Play scenario starts without a leaked Radio selection', firstPlayPrecondition === null,
|
||||||
`selected=${firstPlayPrecondition}`);
|
`selected=${firstPlayPrecondition}`);
|
||||||
await page.select('#radio-filter', 'all');
|
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(() => {
|
await page.evaluate(() => {
|
||||||
const camera = window.__godsEyeView.viewer.camera;
|
const camera = window.__godsEyeView.viewer.camera;
|
||||||
window.__qaFirstPlayFlyToCalls = [];
|
window.__qaFirstPlayFlyToCalls = [];
|
||||||
window.__qaFirstPlayOriginalFlyTo = camera.flyTo;
|
window.__qaFirstPlayOriginalFlyTo = camera.flyTo;
|
||||||
camera.flyTo = (options) => window.__qaFirstPlayFlyToCalls.push(options);
|
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(() => {
|
await page.waitForFunction(() => {
|
||||||
const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
|
const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
|
||||||
return state.audioState === 'playing' || state.audioState === 'error';
|
return state.audioState === 'playing' || state.audioState === 'error';
|
||||||
});
|
});
|
||||||
|
await restoreRadioPanels(page, firstPlayPanels);
|
||||||
const postMicroDragPlayback = await page.evaluate(async () => {
|
const postMicroDragPlayback = await page.evaluate(async () => {
|
||||||
const mod = window.__godsEyeView.dataManager.layers.get('radio').module;
|
const mod = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||||
let state = mod.getUIState();
|
let state = mod.getUIState();
|
||||||
@@ -2593,59 +2489,19 @@ async function main() {
|
|||||||
);
|
);
|
||||||
await page.select('#radio-filter', 'news');
|
await page.select('#radio-filter', 'news');
|
||||||
|
|
||||||
await page.$eval('#context-radio-mini-volume', (input) => {
|
await page.$eval('#radio-volume', (input) => {
|
||||||
input.value = '35';
|
input.value = '35';
|
||||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
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,
|
volume: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().volume,
|
||||||
full: document.getElementById('radio-volume').value,
|
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(
|
check(
|
||||||
'compact disclosure preserves playback, station, filter, and volume continuity',
|
'full Radio volume drives the Radio player state',
|
||||||
compactDisclosureContinuity.closed.expanded === 'false'
|
Math.abs(radioVolume.volume - 0.35) < 0.001 && radioVolume.full === '35' && radioVolume.label === '35%',
|
||||||
&& compactDisclosureContinuity.closed.hidden
|
JSON.stringify(radioVolume),
|
||||||
&& 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,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const voicePause = await page.evaluate(async () => {
|
const voicePause = await page.evaluate(async () => {
|
||||||
@@ -2709,11 +2565,13 @@ async function main() {
|
|||||||
idleVoice.audioState === 'paused',
|
idleVoice.audioState === 'paused',
|
||||||
idleVoice.audioState,
|
idleVoice.audioState,
|
||||||
);
|
);
|
||||||
const manualPlayTarget = await page.evaluate(() => {
|
const manualPlayTarget = await page.evaluate(async () => {
|
||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
manager.setPanelCollapsed('global-context-panel', true);
|
manager.setPanelCollapsed('global-context-panel', false);
|
||||||
manager._setRadioDisclosure(true);
|
manager.setPanelCollapsed('radio-panel', false);
|
||||||
const button = document.getElementById('context-radio-mini-play-btn');
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
|
const button = document.getElementById('radio-play-btn');
|
||||||
|
button.scrollIntoView({ block: 'nearest' });
|
||||||
const rect = button.getBoundingClientRect();
|
const rect = button.getBoundingClientRect();
|
||||||
const style = getComputedStyle(button);
|
const style = getComputedStyle(button);
|
||||||
return {
|
return {
|
||||||
@@ -2728,12 +2586,12 @@ async function main() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
check(
|
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,
|
manualPlayTarget.visible && manualPlayTarget.enabled,
|
||||||
JSON.stringify(manualPlayTarget),
|
JSON.stringify(manualPlayTarget),
|
||||||
);
|
);
|
||||||
if (manualPlayTarget.visible && manualPlayTarget.enabled) {
|
if (manualPlayTarget.visible && manualPlayTarget.enabled) {
|
||||||
await page.click('#context-radio-mini-play-btn');
|
await page.click('#radio-play-btn');
|
||||||
await page.waitForFunction(() => (
|
await page.waitForFunction(() => (
|
||||||
window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'
|
window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'
|
||||||
&& window.__godsEyeView.voiceCommands.status === 'idle'
|
&& window.__godsEyeView.voiceCommands.status === 'idle'
|
||||||
@@ -2781,7 +2639,6 @@ async function main() {
|
|||||||
await page.evaluate(() => window.__godsEyeView.voiceCommands.setStatus('idle', 'QA complete'));
|
await page.evaluate(() => window.__godsEyeView.voiceCommands.setStatus('idle', 'QA complete'));
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
manager._setRadioDisclosure(false);
|
|
||||||
manager.setPanelCollapsed('global-context-panel', false);
|
manager.setPanelCollapsed('global-context-panel', false);
|
||||||
manager.setPanelCollapsed('radio-panel', false);
|
manager.setPanelCollapsed('radio-panel', false);
|
||||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
@@ -2860,14 +2717,20 @@ async function main() {
|
|||||||
};
|
};
|
||||||
const playsBefore = window.__qaRadioPlayCalls.length;
|
const playsBefore = window.__qaRadioPlayCalls.length;
|
||||||
const frozenSignature = style._radioControls._radioTunerBandSignature;
|
const frozenSignature = style._radioControls._radioTunerBandSignature;
|
||||||
|
// The tuner renders from the Radio store; let Vue patch before reading.
|
||||||
|
const rendered = () => Promise.resolve();
|
||||||
pointer('pointerdown', xFor(0));
|
pointer('pointerdown', xFor(0));
|
||||||
|
await rendered();
|
||||||
const left = snapshot();
|
const left = snapshot();
|
||||||
const centerCoordinate = (count - 1) / 2;
|
const centerCoordinate = (count - 1) / 2;
|
||||||
pointer('pointermove', xFor(centerCoordinate));
|
pointer('pointermove', xFor(centerCoordinate));
|
||||||
|
await rendered();
|
||||||
const center = snapshot();
|
const center = snapshot();
|
||||||
pointer('pointermove', xFor(centerCoordinate + 2));
|
pointer('pointermove', xFor(centerCoordinate + 2));
|
||||||
|
await rendered();
|
||||||
const shifted = snapshot();
|
const shifted = snapshot();
|
||||||
pointer('pointermove', xFor(count - 1));
|
pointer('pointermove', xFor(count - 1));
|
||||||
|
await rendered();
|
||||||
const right = snapshot();
|
const right = snapshot();
|
||||||
const shiftedByIndex = new Map(shifted.ticks.map((tick) => [tick.index, tick.x]));
|
const shiftedByIndex = new Map(shifted.ticks.map((tick) => [tick.index, tick.x]));
|
||||||
const sharedTick = center.ticks.find((tick) => shiftedByIndex.has(tick.index));
|
const sharedTick = center.ticks.find((tick) => shiftedByIndex.has(tick.index));
|
||||||
@@ -2924,7 +2787,7 @@ async function main() {
|
|||||||
tapeDelta: absoluteTuner.tapeDelta,
|
tapeDelta: absoluteTuner.tapeDelta,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const tunerCancelRestore = await page.evaluate(() => {
|
const tunerCancelRestore = await page.evaluate(async () => {
|
||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
const style = gev.styleManager;
|
const style = gev.styleManager;
|
||||||
const radio = gev.dataManager.layers.get('radio').module;
|
const radio = gev.dataManager.layers.get('radio').module;
|
||||||
@@ -2957,11 +2820,14 @@ async function main() {
|
|||||||
const targetIndex = before.slot < beforeStations.length - 2 ? before.slot + 2 : before.slot - 2;
|
const targetIndex = before.slot < beforeStations.length - 2 ? before.slot + 2 : before.slot - 2;
|
||||||
pointer('pointerdown', before.slot);
|
pointer('pointerdown', before.slot);
|
||||||
pointer('pointermove', targetIndex);
|
pointer('pointermove', targetIndex);
|
||||||
|
// The tuner renders from the Radio store; let Vue patch before reading.
|
||||||
|
await Promise.resolve();
|
||||||
const shiftedSlot = Number(slider.value);
|
const shiftedSlot = Number(slider.value);
|
||||||
const previewBeforeCancel = gev.viewer.entities.values
|
const previewBeforeCancel = gev.viewer.entities.values
|
||||||
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
|
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
|
||||||
const flyToCallsBeforeCancel = flyToCalls.length;
|
const flyToCallsBeforeCancel = flyToCalls.length;
|
||||||
pointer('pointercancel', targetIndex);
|
pointer('pointercancel', targetIndex);
|
||||||
|
await Promise.resolve();
|
||||||
const afterStations = style._radioControls._radioTunerStations;
|
const afterStations = style._radioControls._radioTunerStations;
|
||||||
const afterState = radio.getUIState();
|
const afterState = radio.getUIState();
|
||||||
const after = {
|
const after = {
|
||||||
@@ -3117,7 +2983,7 @@ async function main() {
|
|||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
await window.__godsEyeView.dataManager.layers.get('radio').module.update();
|
await window.__godsEyeView.dataManager.layers.get('radio').module.update();
|
||||||
});
|
});
|
||||||
const tunerRefreshRelease = await page.evaluate((target) => {
|
const tunerRefreshRelease = await page.evaluate(async (target) => {
|
||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
const slider = document.getElementById('radio-tuner-slider');
|
const slider = document.getElementById('radio-tuner-slider');
|
||||||
const displayedBeforeRelease = document.getElementById('radio-tuner-station').textContent;
|
const displayedBeforeRelease = document.getElementById('radio-tuner-station').textContent;
|
||||||
@@ -3130,6 +2996,10 @@ async function main() {
|
|||||||
clientY: target.clientY,
|
clientY: target.clientY,
|
||||||
}));
|
}));
|
||||||
const state = gev.dataManager.layers.get('radio').module.getUIState();
|
const state = gev.dataManager.layers.get('radio').module.getUIState();
|
||||||
|
// The playback line renders from the Radio store once Vue flushes.
|
||||||
|
const message = await Promise.resolve().then(
|
||||||
|
() => document.getElementById('radio-playback-state').textContent,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
displayedBeforeRelease,
|
displayedBeforeRelease,
|
||||||
frozenGeneration,
|
frozenGeneration,
|
||||||
@@ -3138,7 +3008,7 @@ async function main() {
|
|||||||
plays: window.__qaRadioPlayCalls.length,
|
plays: window.__qaRadioPlayCalls.length,
|
||||||
value: document.getElementById('radio-tuner-value').textContent,
|
value: document.getElementById('radio-tuner-value').textContent,
|
||||||
stationText: document.getElementById('radio-tuner-station').textContent,
|
stationText: document.getElementById('radio-tuner-station').textContent,
|
||||||
message: document.getElementById('radio-playback-state').textContent,
|
message,
|
||||||
targetId: target.id,
|
targetId: target.id,
|
||||||
};
|
};
|
||||||
}, tunerRefreshTarget);
|
}, tunerRefreshTarget);
|
||||||
@@ -3185,6 +3055,8 @@ async function main() {
|
|||||||
pointer('pointermove', targetIndex);
|
pointer('pointermove', targetIndex);
|
||||||
const previewPlays = window.__qaRadioPlayCalls.length;
|
const previewPlays = window.__qaRadioPlayCalls.length;
|
||||||
pointer('pointerup', targetIndex);
|
pointer('pointerup', targetIndex);
|
||||||
|
// The tuner renders from the Radio store; sample once Vue has patched.
|
||||||
|
await Promise.resolve();
|
||||||
const samples = [needle.getBoundingClientRect().left];
|
const samples = [needle.getBoundingClientRect().left];
|
||||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
samples.push(needle.getBoundingClientRect().left);
|
samples.push(needle.getBoundingClientRect().left);
|
||||||
@@ -3835,22 +3707,22 @@ async function main() {
|
|||||||
gev.dataManager.layers.get('flights').enabled = true;
|
gev.dataManager.layers.get('flights').enabled = true;
|
||||||
});
|
});
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true));
|
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const button = document.getElementById('context-radio-toggle-btn');
|
const manager = window.__godsEyeView.styleManager;
|
||||||
if (button.getAttribute('aria-expanded') !== 'true') button.click();
|
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(() => {
|
await page.evaluate(() => {
|
||||||
window.__qaRadioCameraHeight = window.__godsEyeView.viewer.camera.positionCartographic.height;
|
window.__qaRadioCameraHeight = window.__godsEyeView.viewer.camera.positionCartographic.height;
|
||||||
});
|
});
|
||||||
const radioOnlyNextDispatched = await page.evaluate(() => {
|
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;
|
if (!button) return false;
|
||||||
button.click();
|
button.click();
|
||||||
return true;
|
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.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function');
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
const viewer = window.__godsEyeView.viewer;
|
||||||
@@ -3885,7 +3757,7 @@ async function main() {
|
|||||||
flyToCalls: window.__qaRadioFlyToCalls.length,
|
flyToCalls: window.__qaRadioFlyToCalls.length,
|
||||||
flightTargets,
|
flightTargets,
|
||||||
cameraHeight: window.__qaRadioCameraHeight,
|
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))
|
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(),
|
.find((item) => item.name === 'Radio stations')?.entities.values[0]?.point?.pixelSize?.getValue(),
|
||||||
selectedMarkerSize: selectedEntity?.point?.pixelSize?.getValue(),
|
selectedMarkerSize: selectedEntity?.point?.pixelSize?.getValue(),
|
||||||
@@ -3898,7 +3770,7 @@ async function main() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
check(
|
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.state.selected?.id === playing.expectedFallback?.id
|
||||||
&& playing.calls[0] === playing.expectedPrimary?.streamUrl
|
&& playing.calls[0] === playing.expectedPrimary?.streamUrl
|
||||||
&& playing.calls[1] === playing.expectedFallback?.streamUrl,
|
&& playing.calls[1] === playing.expectedFallback?.streamUrl,
|
||||||
@@ -3909,9 +3781,9 @@ async function main() {
|
|||||||
fallback: playing.expectedFallback?.id,
|
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(
|
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
|
playing.flyToCalls === 2
|
||||||
&& Math.abs(playing.flightTargets[0]?.lat - playing.expectedPrimary?.lat) < 0.001
|
&& Math.abs(playing.flightTargets[0]?.lat - playing.expectedPrimary?.lat) < 0.001
|
||||||
&& Math.abs(playing.flightTargets[0]?.lon - playing.expectedPrimary?.lon) < 0.001
|
&& Math.abs(playing.flightTargets[0]?.lon - playing.expectedPrimary?.lon) < 0.001
|
||||||
@@ -3950,11 +3822,11 @@ async function main() {
|
|||||||
};
|
};
|
||||||
return [
|
return [
|
||||||
await run('flights', 'radio-next-btn'),
|
await run('flights', 'radio-next-btn'),
|
||||||
await run('military', 'context-radio-mini-prev-btn'),
|
await run('military', 'radio-prev-btn'),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
check(
|
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
|
trackedRadioControls.every((sample) => sample.afterStationId
|
||||||
&& sample.afterStationId !== sample.beforeStationId
|
&& sample.afterStationId !== sample.beforeStationId
|
||||||
&& sample.flyToDelta === 0
|
&& sample.flyToDelta === 0
|
||||||
@@ -3968,7 +3840,7 @@ async function main() {
|
|||||||
viewer.trackedEntity = undefined;
|
viewer.trackedEntity = undefined;
|
||||||
window.__qaRadioDelayNextFailure = true;
|
window.__qaRadioDelayNextFailure = true;
|
||||||
const beforeFlyToCalls = window.__qaRadioFlyToCalls.length;
|
const beforeFlyToCalls = window.__qaRadioFlyToCalls.length;
|
||||||
document.getElementById('context-radio-mini-next-btn')?.click();
|
document.getElementById('radio-next-btn')?.click();
|
||||||
return { beforeFlyToCalls };
|
return { beforeFlyToCalls };
|
||||||
});
|
});
|
||||||
await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function');
|
await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function');
|
||||||
@@ -4244,7 +4116,7 @@ async function main() {
|
|||||||
window.__godsEyeView.dataManager.layers.get('flights').enabled = false;
|
window.__godsEyeView.dataManager.layers.get('flights').enabled = false;
|
||||||
});
|
});
|
||||||
const radioOnlyNextClicked = await page.evaluate(() => {
|
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;
|
if (!(button instanceof HTMLButtonElement)) return false;
|
||||||
button.click();
|
button.click();
|
||||||
return true;
|
return true;
|
||||||
@@ -4508,106 +4380,12 @@ async function main() {
|
|||||||
&& companion.radioCollapsed && !companion.contextCollapsed && companion.nested,
|
&& 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(() => {
|
await page.evaluate(() => {
|
||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
manager.setPanelCollapsed('global-context-panel', true);
|
manager.setPanelCollapsed('global-context-panel', false);
|
||||||
manager._setRadioDisclosure(true);
|
manager.setPanelCollapsed('radio-panel', false);
|
||||||
});
|
});
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed'));
|
||||||
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.click('#global-context-flights-btn');
|
await page.click('#global-context-flights-btn');
|
||||||
await page.waitForFunction(() => !document.getElementById('context-flights-view').hidden);
|
await page.waitForFunction(() => !document.getElementById('context-flights-view').hidden);
|
||||||
|
|||||||
@@ -106,6 +106,39 @@ try {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// A long rename keeps its editor visible inside the shot row.
|
||||||
|
await page.click('.scene-shot-label', { count: 2 });
|
||||||
|
await page.waitForSelector('.scene-shot-rename', { visible: true });
|
||||||
|
await page.type(
|
||||||
|
'.scene-shot-rename',
|
||||||
|
' with a much longer title that runs well past the shot row',
|
||||||
|
);
|
||||||
|
await page.keyboard.press('End');
|
||||||
|
check(
|
||||||
|
'A long rename keeps the editor visible inside its shot row',
|
||||||
|
await page.evaluate(() => {
|
||||||
|
// The label clips with an ellipsis, which hides an editor that
|
||||||
|
// overflows it, so its content must fit its width.
|
||||||
|
const input = document.querySelector('.scene-shot-rename');
|
||||||
|
const label = input?.closest('.scene-shot-label');
|
||||||
|
const box = input?.getBoundingClientRect();
|
||||||
|
const style = input && getComputedStyle(input);
|
||||||
|
return (
|
||||||
|
!!box &&
|
||||||
|
box.width > 120 &&
|
||||||
|
box.height > 10 &&
|
||||||
|
label.scrollWidth <= label.clientWidth + 1 &&
|
||||||
|
style.visibility === 'visible' &&
|
||||||
|
style.borderTopWidth !== '0px' &&
|
||||||
|
document.activeElement === input
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !document.querySelector('.scene-shot-rename'),
|
||||||
|
);
|
||||||
|
|
||||||
await page.click('#scene-capture-btn');
|
await page.click('#scene-capture-btn');
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => document.querySelectorAll('.scene-shot-row').length === 2,
|
() => document.querySelectorAll('.scene-shot-row').length === 2,
|
||||||
@@ -409,12 +442,12 @@ try {
|
|||||||
return '{"scenes":[]}';
|
return '{"scenes":[]}';
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// A shot row held across disposal must not act once it is revoked.
|
||||||
|
const retainedLabel = document.querySelector('.scene-shot-label');
|
||||||
const stopping = director.destroy();
|
const stopping = director.destroy();
|
||||||
const notificationsAtStop = window.__qaSceneState.length;
|
const notificationsAtStop = window.__qaSceneState.length;
|
||||||
const stoppedSynchronously =
|
const stoppedSynchronously = controls.destroyed;
|
||||||
controls.destroyed &&
|
retainedLabel?.click();
|
||||||
controls.removers.length === 0 &&
|
|
||||||
controls.rowRemovers.length === 0;
|
|
||||||
document.getElementById('scene-new-btn').click();
|
document.getElementById('scene-new-btn').click();
|
||||||
controls.run('capture');
|
controls.run('capture');
|
||||||
finish();
|
finish();
|
||||||
@@ -423,6 +456,7 @@ try {
|
|||||||
await director.destroy();
|
await director.destroy();
|
||||||
return {
|
return {
|
||||||
stoppedSynchronously,
|
stoppedSynchronously,
|
||||||
|
heldRowAction: Boolean(retainedLabel),
|
||||||
stateStopped: window.__qaSceneState.length === notificationsAtStop,
|
stateStopped: window.__qaSceneState.length === notificationsAtStop,
|
||||||
sameProject: director._project === project,
|
sameProject: director._project === project,
|
||||||
noLateStatus:
|
noLateStatus:
|
||||||
@@ -432,7 +466,9 @@ try {
|
|||||||
});
|
});
|
||||||
check(
|
check(
|
||||||
'Disposal revokes fixed and row actions before waiting for scene work',
|
'Disposal revokes fixed and row actions before waiting for scene work',
|
||||||
teardown.stoppedSynchronously && teardown.rowsGone,
|
teardown.stoppedSynchronously &&
|
||||||
|
teardown.heldRowAction &&
|
||||||
|
teardown.rowsGone,
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'Late import completion cannot replace the disposed project or status',
|
'Late import completion cannot replace the disposed project or status',
|
||||||
|
|||||||
Loaded 100 of 810 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user