mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 11:54:02 +00:00
Compare commits
144
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70123fe4a9 | ||
|
|
fbcac37f13 | ||
|
|
9049357916 | ||
|
|
8f612ba858 | ||
|
|
6ffdf30795 | ||
|
|
2bb0b1e367 | ||
|
|
8b6bbf0f21 | ||
|
|
96bc73181b | ||
|
|
c6bab5dcaa | ||
|
|
30c56a8ac8 | ||
|
|
910a691494 | ||
|
|
d0ef3a902b | ||
|
|
d6b553646e | ||
|
|
cf915e8c16 | ||
|
|
9c9ba376e4 | ||
|
|
764b40950f | ||
|
|
c6f043d3fd | ||
|
|
e5f8e5753a | ||
|
|
2adec097de | ||
|
|
e40a4c78d5 | ||
|
|
1d9c731c10 | ||
|
|
68855b2089 | ||
|
|
bc69e8a4ee | ||
|
|
0b1c5d0f23 | ||
|
|
e3baeec223 | ||
|
|
f95324acb1 | ||
|
|
de0ff6cd2d | ||
|
|
24bc8fce7e | ||
|
|
1f2479d3ab | ||
|
|
21f284f79c | ||
|
|
60a3a2808e | ||
|
|
468590485b | ||
|
|
3e48e453d0 | ||
|
|
3837e453ca | ||
|
|
48f06c68c8 | ||
|
|
bdc7692292 | ||
|
|
92fd2b0c2e | ||
|
|
a5143f4467 | ||
|
|
03f2e2a9b9 | ||
|
|
8d7f82f110 | ||
|
|
bcd24c458b | ||
|
|
6060c2cee8 | ||
|
|
bf758a0abb | ||
|
|
e32988ae5e | ||
|
|
52d82b453c | ||
|
|
fd1c09dbbc | ||
|
|
5652236a27 | ||
|
|
9f165e68f3 | ||
|
|
fefdf242ee | ||
|
|
e9c0dc5405 | ||
|
|
14f06df735 | ||
|
|
2fc99e7c19 | ||
|
|
fb30287898 | ||
|
|
ad8adc85f4 | ||
|
|
d51ea4007b | ||
|
|
b51f1bf7c5 | ||
|
|
6035de88e2 | ||
|
|
f008120b4c | ||
|
|
00d1bee127 | ||
|
|
89b07fddfd | ||
|
|
fe32e47bdf | ||
|
|
9fde0a2a59 | ||
|
|
2c4c66c710 | ||
|
|
5f36eb0e0d | ||
|
|
7204d66f0b | ||
|
|
c5c0803901 | ||
|
|
66869b09ef | ||
|
|
47f7eeb00e | ||
|
|
1cfff5803a | ||
|
|
a40e5c334c | ||
|
|
f86dcc9179 | ||
|
|
42b17ffab6 | ||
|
|
07c8d7a17c | ||
|
|
95c0070412 | ||
|
|
ea69bb4ce2 | ||
|
|
3afbd0c638 | ||
|
|
3ad3cc2ab7 | ||
|
|
34906c9abb | ||
|
|
e5d15f24d5 | ||
|
|
a6d20120a7 | ||
|
|
315c561a7b | ||
|
|
8e40d2689b | ||
|
|
f3adccd01a | ||
|
|
c664f8eacb | ||
|
|
39c648094b | ||
|
|
be53c338c8 | ||
|
|
d413362467 | ||
|
|
b80c54b7f1 | ||
|
|
ac5164b14b | ||
|
|
03c168d7c9 | ||
|
|
ab8691383b | ||
|
|
238a3d0634 | ||
|
|
81ffa5b663 | ||
|
|
00cce1e672 | ||
|
|
33381c5981 | ||
|
|
57da1090d0 | ||
|
|
c6de459436 | ||
|
|
bd7ef69843 | ||
|
|
c35eba1704 | ||
|
|
fc7ff1055c | ||
|
|
c489e65796 | ||
|
|
ce1ad3b95a | ||
|
|
468d5078e3 | ||
|
|
cfc83a6078 | ||
|
|
645e13aeaa | ||
|
|
818d2c8295 | ||
|
|
857b007fb1 | ||
|
|
0ec6735440 | ||
|
|
a2be947aba | ||
|
|
0d3c1ef1d9 | ||
|
|
1a6687632d | ||
|
|
dbfbfcceb9 | ||
|
|
ebdd4628db | ||
|
|
5ed86200e5 | ||
|
|
a55450e2e7 | ||
|
|
7713fb8ae8 | ||
|
|
7b223d7d7b | ||
|
|
fa840ae3c3 | ||
|
|
a7c16a701c | ||
|
|
4dd5a313b3 | ||
|
|
f967218a04 | ||
|
|
54750a01e5 | ||
|
|
563df3bad9 | ||
|
|
b775bfed70 | ||
|
|
1ef9be2248 | ||
|
|
692e99729c | ||
|
|
9b918d475a | ||
|
|
d0f6f5c0d8 | ||
|
|
49b65ddc55 | ||
|
|
19b4a7a7e0 | ||
|
|
dc56ada3a5 | ||
|
|
60dad0c35e | ||
|
|
0ac32f82ba | ||
|
|
d026952397 | ||
|
|
01c764d4fd | ||
|
|
8f16d10f7a | ||
|
|
7a421c35fc | ||
|
|
09ea8f33b9 | ||
|
|
daedd76acc | ||
|
|
bf2c96db0a | ||
|
|
e3c4e88d6f | ||
|
|
9059cd942f | ||
|
|
55f979c128 | ||
|
|
feba5af0b9 |
No files matched your search
@@ -9,6 +9,8 @@ coverage
|
|||||||
pinokio/ENVIRONMENT
|
pinokio/ENVIRONMENT
|
||||||
pinokio/.installed
|
pinokio/.installed
|
||||||
src/data/local_data/
|
src/data/local_data/
|
||||||
|
# Written by scripts/record-camera-goldens.mjs and compared byte for byte.
|
||||||
|
src/render/conformance/goldens/
|
||||||
*.geojson
|
*.geojson
|
||||||
*.geojsonl
|
*.geojsonl
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ How to read this:
|
|||||||
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
|
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
|
||||||
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
|
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
|
||||||
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
|
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
|
||||||
|
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
|
||||||
|
| **OpenSnowMap** (`www.opensnowmap.org`) | Ski Pistes layer: raster overlay of ski runs and lifts (#10) | Tiles CC BY-SA; data ODbL 1.0 via OpenStreetMap; bulk tile download forbidden without prior agreement | "Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)" |
|
||||||
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover |
|
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover |
|
||||||
|
|
||||||
### Local receiver input (not fetched from a service)
|
### Local receiver input (not fetched from a service)
|
||||||
@@ -118,6 +120,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
|||||||
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
|
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
|
||||||
|
|
||||||
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
||||||
|
- **Sea Marks and Ski Pistes (#10).** Two keyless raster overlay layers under Infrastructure, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline.
|
||||||
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
|
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
|
||||||
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
|
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
|
||||||
<a id="gnss-integrity"></a>
|
<a id="gnss-integrity"></a>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat
|
|||||||
|
|
||||||
## ⚡ Quick Start
|
## ⚡ Quick Start
|
||||||
|
|
||||||
**Start without an account or API keys.** Every path opens the same app with
|
**Local setup needs no account or API keys.** The app opens with
|
||||||
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
|
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
|
||||||
unreachable. Flights, military traffic, satellites, earthquakes, public
|
unreachable. Flights, military traffic, satellites, earthquakes, public
|
||||||
cameras, radio, and launches are available without keys.
|
cameras, radio, and launches are available without keys.
|
||||||
@@ -212,7 +212,7 @@ Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front o
|
|||||||
|
|
||||||
On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`.
|
On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`.
|
||||||
|
|
||||||
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)).
|
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). The map routes compare the browser's `Origin` with the `Host` the server sees, so the proxy must pass the original `Host` and, when it terminates TLS, `X-Forwarded-Proto`. Caddy does both by default; for nginx add `proxy_set_header Host $http_host;`, `proxy_set_header X-Forwarded-Proto $scheme;` and `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`. On an install outside Docker, that forwarding header also rate-limits the proxy's users apart from your own browser. Under the default Docker bridge network every client on the host, your browser included, reaches the container from its gateway address and shares one Overpass bucket.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -236,7 +236,11 @@ the container.
|
|||||||
|
|
||||||
### Path 4 — GitHub Codespaces (cloud, no local install)
|
### Path 4 — GitHub Codespaces (cloud, no local install)
|
||||||
|
|
||||||
Runs the same dev server in a cloud workspace, so Node and the dependencies
|
Codespaces requires a GitHub account. Overpass queries and OpenAI voice token
|
||||||
|
requests return HTTP 403 through its forwarded HTTPS URL, even with API keys
|
||||||
|
in `.env` and `GEV_ALLOWED_HOSTS` configured. Use a local setup for these features.
|
||||||
|
|
||||||
|
The codespace runs the dev server in a cloud workspace, so Node and the dependencies
|
||||||
never touch your machine. The globe still renders in your own browser, so it
|
never touch your machine. The globe still renders in your own browser, so it
|
||||||
needs the same graphics there as any other path. GitHub Free includes 120
|
needs the same graphics there as any other path. GitHub Free includes 120
|
||||||
core-hours a month with no card on file — about 60 hours on the default 2-core
|
core-hours a month with no card on file — about 60 hours on the default 2-core
|
||||||
@@ -265,14 +269,13 @@ ignored on purpose, so pass that exact name.
|
|||||||
|
|
||||||
⚠️ **A codespace is reachable from the internet.** Treat it as the
|
⚠️ **A codespace is reachable from the internet.** Treat it as the
|
||||||
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
|
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
|
||||||
Settings does not open on the forwarded URL, so put keys in the workspace
|
Settings is unavailable on the forwarded URL. Never commit your workspace
|
||||||
`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the
|
`.env`. [SECURITY.md](SECURITY.md) has the threat model.
|
||||||
threat model.
|
|
||||||
|
|
||||||
### Then power it up — in the app, not in a file
|
### Then power it up — in the app, not in a file
|
||||||
|
|
||||||
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
|
For a local setup, keys are upgrades, not prerequisites. When you want one,
|
||||||
chip in the bottom-right corner: Provider Settings lists every supported key,
|
click the **POWER UP** chip in the bottom-right corner: Provider Settings lists every supported key,
|
||||||
what it switches on, and where to get it. Paste, hit **SAVE KEYS**, and the app
|
what it switches on, and where to get it. Paste, hit **SAVE KEYS**, and the app
|
||||||
restarts itself with the new capability on. Once everything is configured the
|
restarts itself with the new capability on. Once everything is configured the
|
||||||
chip reads **POWERED UP** — and if a compact layout hides it, `?setup=1`
|
chip reads **POWERED UP** — and if a compact layout hides it, `?setup=1`
|
||||||
|
|||||||
+1
-1
@@ -80,7 +80,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
|||||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
|
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights` and `/api/overpass`) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map needs both to render behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
|
||||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||||
|
|
||||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Add Sea Marks (OpenSeaMap) and Ski Pistes (OpenSnowMap) as two raster
|
||||||
|
overlay layers under Infrastructure. They drape on the globe stacks and on
|
||||||
|
Google 3D, and share links keep each one (Tom-Neverwinter, #114).
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
- Keep Overpass-backed layers working behind a reverse proxy: `/api/overpass`
|
||||||
|
now uses the keyless same-site gate, admits forwarding headers and still
|
||||||
|
refuses cross-site requests. Direct loopback requests keep their own
|
||||||
|
admission window; proxied requests, including those a local reverse proxy
|
||||||
|
relays over loopback, are admitted as remote traffic keyed by their socket,
|
||||||
|
and IPv6 peers are keyed by /64, so neither LAN peers nor proxied users can
|
||||||
|
exhaust the owner's admission. The upstream budget stays shared, so an owner
|
||||||
|
cache miss can still be rate-limited (asturksever, #1308).
|
||||||
|
- Give the tracking arrival check a wall-clock budget per observed frame and
|
||||||
|
retry only an inconclusive timeout: a timeout keeps every angle it
|
||||||
|
measured, including a late frame's, and a wrong one fails without retry, so slow software-rendered
|
||||||
|
frames no longer fail the check and a real violation is never retried into a
|
||||||
|
pass (asturksever, #1308).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Military aircraft that leave the adsb.lol feed fade and are removed again.
|
||||||
|
The feed's routine position-less rows no longer mark every snapshot partial,
|
||||||
|
which kept vanished aircraft at full opacity for up to five minutes; those
|
||||||
|
rows keep only their own aircraft, and a likely-landed one still leaves on
|
||||||
|
the landed limit (Aditya, @Rohilalala, #1435).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- A terrain request the proxy can only partly answer (a 429 pause, or a point
|
||||||
|
the upstream returns without a height) now keeps the real heights it has
|
||||||
|
instead of putting the whole chunk on the geoid (quanticsoul4772, #1456).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Radio Next, Previous and the tuning dial no longer start a broadcaster stream
|
||||||
|
before Play is pressed; while a stream plays, it follows the new station.
|
||||||
|
Play reads Pause while a stream connects, and a tuner release onto a station
|
||||||
|
the directory removed or changed no longer leaves a Resume for another
|
||||||
|
station (gekh, #1474).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Write the AI HUD summary in the interface language, led by the HUD's own
|
||||||
|
translated feed-state warning. A partial layer no longer reads as off or
|
||||||
|
nominal in the HUD, voice and analyst answers (German, @gekh, #1488).
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Renderer conformance</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font:
|
||||||
|
13px/1.4 system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #0d1117;
|
||||||
|
color: #e6edf3;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid #30363d;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 0 12px 0 0;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
#stage {
|
||||||
|
width: 640px;
|
||||||
|
height: 360px;
|
||||||
|
position: fixed;
|
||||||
|
left: -10000px;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
border-bottom: 1px solid #21262d;
|
||||||
|
padding: 4px 8px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.pass {
|
||||||
|
color: #3fb950;
|
||||||
|
}
|
||||||
|
.fail {
|
||||||
|
color: #f85149;
|
||||||
|
}
|
||||||
|
.skip {
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
.views {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
figure {
|
||||||
|
margin: 0;
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
figure img {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
figure .row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
figcaption {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Renderer conformance</h1>
|
||||||
|
<label
|
||||||
|
>Renderer
|
||||||
|
<select id="renderer">
|
||||||
|
<option value="cesium">cesium (scene renderer)</option>
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<button id="logic">Run logic suite (stub)</button>
|
||||||
|
<button id="logicCesium">Run logic suite (cesium)</button>
|
||||||
|
<button id="check">Check scenes</button>
|
||||||
|
<button id="record">Record references</button>
|
||||||
|
<span id="status"></span>
|
||||||
|
</header>
|
||||||
|
<div id="stage"></div>
|
||||||
|
<main>
|
||||||
|
<section id="logicResults"></section>
|
||||||
|
<section class="views" id="views"></section>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="./main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* The conformance page: runs the logic suite in the browser, and draws the
|
||||||
|
* reference scenes on a renderer to check them against the stored
|
||||||
|
* references (or record new ones). Driven by hand or through the
|
||||||
|
* chrome-devtools MCP; `window.conformance` exposes the same actions.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
DEFAULT_TOLERANCE,
|
||||||
|
runConformance,
|
||||||
|
} from '../../src/render/conformance/index.js';
|
||||||
|
import {
|
||||||
|
REFERENCE_SCENES,
|
||||||
|
sceneSupport,
|
||||||
|
} from '../../src/render/conformance/scenes.js';
|
||||||
|
import {
|
||||||
|
compareImages,
|
||||||
|
drawScene,
|
||||||
|
referenceName,
|
||||||
|
} from '../../src/render/conformance/visual.js';
|
||||||
|
import { createStubHarness } from '../../src/render/stub/harness.js';
|
||||||
|
import { toEcef } from '../../src/render/stub/math.js';
|
||||||
|
import { createCesiumSceneRenderer } from '../../src/render/cesium/sceneRenderer.js';
|
||||||
|
import {
|
||||||
|
CESIUM_CHECKS,
|
||||||
|
createCesiumHarness,
|
||||||
|
} from '../../src/render/cesium/harness.js';
|
||||||
|
|
||||||
|
const REFERENCES = '/src/render/conformance/goldens/visual/';
|
||||||
|
const SAVE = '/__conformance/references/';
|
||||||
|
const ctx = { toEcef, assets: { model: '/models/c172.glb' } };
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const status = (text) => ($('status').textContent = text);
|
||||||
|
|
||||||
|
const RENDERERS = {
|
||||||
|
cesium: () => createCesiumSceneRenderer({ container: $('stage') }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const HARNESSES = {
|
||||||
|
stub: () => [createStubHarness('stub (browser)'), {}],
|
||||||
|
cesium: () => [
|
||||||
|
createCesiumHarness({ model: ctx.assets.model }),
|
||||||
|
{ only: (id) => CESIUM_CHECKS.has(id) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Runs the logic suite on `which`; `all` runs every check, listed or not. */
|
||||||
|
async function runLogic(which = 'stub', { all = false } = {}) {
|
||||||
|
status(`running the logic suite on ${which}…`);
|
||||||
|
const [harness, options] = HARNESSES[which]();
|
||||||
|
const report = await runConformance(harness, all ? {} : options);
|
||||||
|
$('logicResults').innerHTML =
|
||||||
|
`<p>${report.harness}: <b class="pass">${report.passed} passed</b>, <b class="fail">${report.failed} failed</b>, ${report.skipped} skipped</p>
|
||||||
|
<table>${report.results
|
||||||
|
.filter((r) => r.status !== 'pass')
|
||||||
|
.map(
|
||||||
|
(r) =>
|
||||||
|
`<tr><td class="${r.status}">${r.status}</td><td>${r.id}</td><td>${escape(r.detail ?? '')}</td></tr>`,
|
||||||
|
)
|
||||||
|
.join('')}</table>`;
|
||||||
|
status('');
|
||||||
|
return {
|
||||||
|
passed: report.passed,
|
||||||
|
failed: report.failed,
|
||||||
|
skipped: report.skipped,
|
||||||
|
failures: report.results.filter((r) => r.status === 'fail'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const escape = (s) =>
|
||||||
|
s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]);
|
||||||
|
|
||||||
|
async function pixels(blob) {
|
||||||
|
const bitmap = await createImageBitmap(blob);
|
||||||
|
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||||
|
const g = canvas.getContext('2d');
|
||||||
|
g.drawImage(bitmap, 0, 0);
|
||||||
|
return g.getImageData(0, 0, bitmap.width, bitmap.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object URLs shown for the current run, revoked when the next one starts.
|
||||||
|
let objectUrls = [];
|
||||||
|
function objectUrl(blob) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
objectUrls.push(url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toUrl(imageData) {
|
||||||
|
const canvas = new OffscreenCanvas(imageData.width, imageData.height);
|
||||||
|
canvas.getContext('2d').putImageData(imageData, 0, 0);
|
||||||
|
return objectUrl(await canvas.convertToBlob());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The views the stored manifest lists, or none. */
|
||||||
|
async function storedViews() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(REFERENCES + 'manifest.json', {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
return res.ok ? ((await res.json()).views ?? []) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reference(name) {
|
||||||
|
const res = await fetch(REFERENCES + name, { cache: 'no-store' });
|
||||||
|
return res.ok && res.headers.get('content-type')?.startsWith('image/')
|
||||||
|
? res.blob()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draws every scene; records references or compares against them. */
|
||||||
|
async function runScenes({ record = false, only } = {}) {
|
||||||
|
const which = $('renderer').value;
|
||||||
|
const renderer = await RENDERERS[which]();
|
||||||
|
const results = [];
|
||||||
|
$('views').innerHTML = '';
|
||||||
|
for (const url of objectUrls) URL.revokeObjectURL(url);
|
||||||
|
objectUrls = [];
|
||||||
|
try {
|
||||||
|
for (const scene of REFERENCE_SCENES) {
|
||||||
|
if (only && !only.includes(scene.id)) continue;
|
||||||
|
const unsupported = sceneSupport(scene, renderer, ctx);
|
||||||
|
if (unsupported) {
|
||||||
|
results.push({ scene: scene.id, status: 'skip', detail: unsupported });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
status(`${scene.id}…`);
|
||||||
|
await drawScene(renderer, scene, ctx, async (view, capture, settle) => {
|
||||||
|
const name = referenceName(scene.id, view.name);
|
||||||
|
const figure = document.createElement('figure');
|
||||||
|
$('views').append(figure);
|
||||||
|
if (settle.state !== 'settled') {
|
||||||
|
const detail = `${settle.state}: ${settle.blockers.join(', ')}`;
|
||||||
|
results.push({
|
||||||
|
scene: scene.id,
|
||||||
|
view: view.name,
|
||||||
|
status: 'fail',
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
figure.innerHTML = `<img src="${objectUrl(capture)}"><figcaption class="fail">${name}: ${escape(detail)}</figcaption>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (record) {
|
||||||
|
const res = await fetch(SAVE + name, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: capture,
|
||||||
|
});
|
||||||
|
results.push({
|
||||||
|
scene: scene.id,
|
||||||
|
view: view.name,
|
||||||
|
status: res.ok ? 'recorded' : 'fail',
|
||||||
|
detail: res.ok ? undefined : `save ${res.status}`,
|
||||||
|
});
|
||||||
|
figure.innerHTML = `<img src="${objectUrl(capture)}"><figcaption>${name}: recorded</figcaption>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const refBlob = await reference(name);
|
||||||
|
if (!refBlob) {
|
||||||
|
results.push({
|
||||||
|
scene: scene.id,
|
||||||
|
view: view.name,
|
||||||
|
status: 'fail',
|
||||||
|
detail: 'no reference',
|
||||||
|
});
|
||||||
|
figure.innerHTML = `<img src="${objectUrl(capture)}"><figcaption class="fail">${name}: no reference</figcaption>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [actual, expected] = await Promise.all([
|
||||||
|
pixels(capture),
|
||||||
|
pixels(refBlob),
|
||||||
|
]);
|
||||||
|
const diff = new Uint8ClampedArray(actual.data.length);
|
||||||
|
const outcome = compareImages(actual, expected, { diff });
|
||||||
|
results.push({
|
||||||
|
scene: scene.id,
|
||||||
|
view: view.name,
|
||||||
|
status: outcome.match ? 'pass' : 'fail',
|
||||||
|
diffFraction: outcome.diffFraction,
|
||||||
|
solidPixels: outcome.solidPixels,
|
||||||
|
mismatchedPixels: outcome.mismatchedPixels,
|
||||||
|
...(outcome.reason ? { detail: outcome.reason } : {}),
|
||||||
|
});
|
||||||
|
const diffUrl = outcome.reason
|
||||||
|
? ''
|
||||||
|
: await toUrl(new ImageData(diff, actual.width, actual.height));
|
||||||
|
figure.innerHTML = `<div class="row"><img src="${objectUrl(capture)}" title="${which}"><img src="${objectUrl(refBlob)}" title="reference"><img src="${diffUrl}" title="difference"></div>
|
||||||
|
<figcaption class="${outcome.match ? 'pass' : 'fail'}">${name}: ${outcome.solidPixels} solid, ${outcome.mismatchedPixels} unexcused, ${(outcome.diffFraction * 100).toFixed(3)}% differ ${outcome.reason ?? ''}</figcaption>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (record) {
|
||||||
|
// A partial recording keeps the other scenes' entries.
|
||||||
|
const kept = only
|
||||||
|
? (await storedViews()).filter(
|
||||||
|
(view) => !only.some((id) => view.startsWith(`${id}-`)),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const recorded = results
|
||||||
|
.filter((r) => r.status === 'recorded')
|
||||||
|
.map((r) => referenceName(r.scene, r.view));
|
||||||
|
await fetch(SAVE + 'manifest.json', {
|
||||||
|
method: 'PUT',
|
||||||
|
body:
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
renderer: which,
|
||||||
|
size: {
|
||||||
|
width: renderer.canvas.width,
|
||||||
|
height: renderer.canvas.height,
|
||||||
|
},
|
||||||
|
views: [...kept, ...recorded],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
) + '\n',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
renderer.destroy();
|
||||||
|
status('');
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('logic').onclick = () => runLogic('stub');
|
||||||
|
$('logicCesium').onclick = () => runLogic('cesium');
|
||||||
|
$('check').onclick = () => runScenes();
|
||||||
|
$('record').onclick = () => runScenes({ record: true });
|
||||||
|
window.conformance = { runLogic, runScenes };
|
||||||
@@ -81,6 +81,100 @@ constructed component objects; the component instances themselves remain mutable
|
|||||||
`ready` means all constructors have returned. Background feed activity and the
|
`ready` means all constructors have returned. Background feed activity and the
|
||||||
standalone share-restoration result keep their existing separate contracts.
|
standalone share-restoration result keep their existing separate contracts.
|
||||||
|
|
||||||
|
## Pointer input
|
||||||
|
|
||||||
|
`gods-eye-view/input` exports `inputFor(viewer)`, the scene's one input
|
||||||
|
dispatcher. Every layer and tool takes its pointer input from it, and so can
|
||||||
|
the caller: one `ScreenSpaceEventHandler` per scene, and a click picked once
|
||||||
|
and handed to every subscriber.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { inputFor } from 'gods-eye-view/input';
|
||||||
|
|
||||||
|
const input = inputFor(viewer);
|
||||||
|
const off = input.onClick((event) => {
|
||||||
|
if (event.ownerId === 'flights') showFlight(event.id);
|
||||||
|
});
|
||||||
|
// Later:
|
||||||
|
off();
|
||||||
|
```
|
||||||
|
|
||||||
|
`onClick` runs while no tool holds the pointer (the draw tool, the imagery
|
||||||
|
box), so a vertex placed on an aircraft is not also a click on it. Its event
|
||||||
|
reads lazily; a subscriber that reads nothing costs no pick:
|
||||||
|
|
||||||
|
| Member | What it is |
|
||||||
|
| ------------------ | -------------------------------------------------------------------- |
|
||||||
|
| `screenPosition` | Where the user clicked, in CSS pixels; for DOM and overlay hit-tests |
|
||||||
|
| `position` | Where that lands in the pick buffer (corrected under sensor styles) |
|
||||||
|
| `gesture` | `{ travelPx, durationMs }` of the press, to tell a click from a drag |
|
||||||
|
| `pick` | `scene.pick` at `position` |
|
||||||
|
| `hit` | `pick`, or on a coarse pointer the contact under a surface pick |
|
||||||
|
| `id`, `ownerId` | The hit's pick id and the layer that registered it, or `null` |
|
||||||
|
| `meta` | The hit's own metadata, when its pick carries any |
|
||||||
|
| `surface` | Whether the hit is the map itself |
|
||||||
|
| `drill(limit)` | `scene.drillPick`, shared by every subscriber of the click |
|
||||||
|
| `pickWindow(w, h)` | `scene.pick` over a `w`×`h` window around `position` |
|
||||||
|
| `ground()` | The world position under the click, or `null` over the sky |
|
||||||
|
|
||||||
|
`onPointer(type, fn, { lease })` subscribes to `down`, `move`, `up`, `click`
|
||||||
|
or `dblclick`; with a lease it runs only while that lease is current. A tool
|
||||||
|
takes its lease with `claimPointer(ownerId)` from the same export (and gives
|
||||||
|
it back with `releasePointer(lease)`), which also keeps every ambient
|
||||||
|
subscriber off its clicks; `isPointerFree()` and `pointerOwner()` report who
|
||||||
|
holds it. `suspendCamera(reason)` and `suppressStockClicks()` return their
|
||||||
|
own release: camera input and the viewer's stock select and double-click-track
|
||||||
|
come back when the last hold is released, so two holders cannot undo each
|
||||||
|
other. The dispatcher owns the camera controller's `enableInputs`: a direct
|
||||||
|
write (Cesium's camera flights make one when they start and end) is kept, but
|
||||||
|
reads true only while no hold is out. A caller-supplied `createScene` that creates its own viewer registers
|
||||||
|
`defer(() => inputFor(viewer).destroy())` right after it; the standalone scene
|
||||||
|
does this and returns the dispatcher as `scene.input`.
|
||||||
|
|
||||||
|
## Height queries
|
||||||
|
|
||||||
|
`gods-eye-view/queries/heights` exports `heightsFor(scene)`, the one place
|
||||||
|
height queries go. Layers, tools and the camera ask it instead of calling
|
||||||
|
`scene.sampleHeight`, `globe.getHeight`, `scene.clampToHeight` or the
|
||||||
|
`*MostDetailed` variants themselves, so a renderer that answers heights another
|
||||||
|
way plugs in without touching them.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { heightsFor } from 'gods-eye-view/queries/heights';
|
||||||
|
|
||||||
|
const heights = heightsFor(viewer.scene);
|
||||||
|
const ground = heights.now({ lon, lat }, { exclude: [ownMarkers] });
|
||||||
|
if (ground !== null) place(ground);
|
||||||
|
```
|
||||||
|
|
||||||
|
| Member | What it answers |
|
||||||
|
| ----------------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| `now(point, opts)` | Height in metres from what is loaded, or `null` |
|
||||||
|
| `clampNow(position, opts)` | The `Cartesian3` moved onto the drawn surface, or `null` |
|
||||||
|
| `batch(points, opts)` | A promise of heights once the most detailed tiles stream; `null` per miss |
|
||||||
|
| `clampBatch(positions, opts)` | A promise of clamped copies of the positions; `null` per miss |
|
||||||
|
| `supported()` | `{ surface, clamp }`: whether the drawn surface can be queried at all |
|
||||||
|
|
||||||
|
A point is `{ lon, lat }` in degrees or a `Cesium.Cartographic` in radians.
|
||||||
|
`against: 'surface'` (the default) reads what is drawn, 3D tiles and globe;
|
||||||
|
`against: 'terrain'` reads the loaded terrain tiles alone. `exclude` lists the
|
||||||
|
primitives and entities a query sees through. Every miss is `null`, including
|
||||||
|
a Cesium throw for missing depth-texture support, and every caller treats it
|
||||||
|
as one. Cesium answers `now()` synchronously from the scene, so it costs what
|
||||||
|
the direct call did.
|
||||||
|
|
||||||
|
## Cesium internals
|
||||||
|
|
||||||
|
Core reaches private Cesium fields only through
|
||||||
|
`src/render/cesium/internals.js`, one named helper per field (the main and
|
||||||
|
offscreen pick views, a billboard's collection, a cluster's point collection,
|
||||||
|
the widget's render-loop latch, the WebGL context and whether a camera flight
|
||||||
|
is under way). `internals.test.mjs` fails when the installed Cesium stops
|
||||||
|
declaring one of them, and when a private field is reached anywhere else.
|
||||||
|
Imagery that rations its tile requests states the policy as a hook with
|
||||||
|
`withTileRequestHook(provider, { requestImage, credit })` from
|
||||||
|
`src/render/cesium/tileRequests.js`, which leaves the provider untouched.
|
||||||
|
|
||||||
## Standalone wiring
|
## Standalone wiring
|
||||||
|
|
||||||
`src/main.js` reads the existing browser configuration and starts
|
`src/main.js` reads the existing browser configuration and starts
|
||||||
|
|||||||
@@ -33,12 +33,13 @@ export inventory. Use declared exports rather than reaching into internal files.
|
|||||||
| `src/layers/<family>/` | Source acquisition, records and Cesium resources with explicit controller/renderer owners |
|
| `src/layers/<family>/` | Source acquisition, records and Cesium resources with explicit controller/renderer owners |
|
||||||
| `src/sources/` | Portable protocols and source contracts; request state belongs to each factory instance |
|
| `src/sources/` | Portable protocols and source contracts; request state belongs to each factory instance |
|
||||||
| `src/services/` | Supplied application operations; scene construction owns caches and cancellation |
|
| `src/services/` | Supplied application operations; scene construction owns caches and cancellation |
|
||||||
|
| `src/render/` | The renderer contract, the stub renderer, the conformance suite and Cesium renderer pieces |
|
||||||
| `src/voice/` | Portable action schemas/session, common controls, action execution and separate protocol adapters |
|
| `src/voice/` | Portable action schemas/session, common controls, action execution and separate protocol adapters |
|
||||||
| `server/providers/` | Node route factories, process-scoped provider caches and shutdown cleanup |
|
| `server/providers/` | Node route factories, process-scoped provider caches and shutdown cleanup |
|
||||||
| `server/standalone/` | Environment, local settings writes and server composition |
|
| `server/standalone/` | Environment, local settings writes and server composition |
|
||||||
|
|
||||||
See [application construction](APPLICATION.md) and the
|
See [application construction](APPLICATION.md) and the
|
||||||
[infrastructure contract](INFRASTRUCTURE-LAYERS.md) for construction interfaces.
|
[infrastructure contract](INFRASTRUCTURE-LAYERS.md) for construction interfaces, and the [renderer contract](RENDERER-CONTRACT.md) for what a renderer provides.
|
||||||
|
|
||||||
## Import direction gates
|
## Import direction gates
|
||||||
|
|
||||||
|
|||||||
+119
-68
@@ -1197,12 +1197,12 @@ addition to the locally accumulated trail, never a promise of complete coverage.
|
|||||||
Snapshot coverage, completeness and freshness are separate fields. A partially
|
Snapshot coverage, completeness and freshness are separate fields. A partially
|
||||||
admitted aircraft snapshot retains absent contacts for up to five minutes before
|
admitted aircraft snapshot retains absent contacts for up to five minutes before
|
||||||
the usual missed-poll eviction. An OpenSky row with an ICAO24 address but null
|
the usual missed-poll eviction. An OpenSky row with an ICAO24 address but null
|
||||||
longitude and latitude is a contact without a position, not a malformed row: it
|
longitude and latitude, or a readsb row with a hex but no `lat`/`lon`, is a
|
||||||
leaves the snapshot complete and is listed in `retainIds`, so civil flights
|
contact without a position, not a malformed row: it leaves the snapshot complete
|
||||||
retain that aircraft under the same five-minute bound, unless it is likely
|
and is listed in `retainIds`, so civil and military flights retain that aircraft
|
||||||
landed, while other absent aircraft fade and leave on the missed-poll limits. A
|
under the same five-minute bound, unless it is likely landed, while other absent
|
||||||
nonempty snapshot with no positioned rows is still malformed. Military readsb
|
aircraft fade and leave on the missed-poll limits. A nonempty snapshot with no
|
||||||
admission is unchanged. Unknown snapshot times remain unknown in stats. An invalid nonempty
|
positioned rows is still malformed. Unknown snapshot times remain unknown in stats. An invalid nonempty
|
||||||
snapshot retains the previous display. Empty vessel refreshes retain the existing
|
snapshot retains the previous display. Empty vessel refreshes retain the existing
|
||||||
first-connect grace and warm-data behavior. Known source failures have bounded
|
first-connect grace and warm-data behavior. Known source failures have bounded
|
||||||
messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
|
messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
|
||||||
@@ -1414,7 +1414,7 @@ and a Denver coordinate underground. Search now resolves the application's
|
|||||||
ground-floor elevation (bounded wait, same ellipsoidal datum) before framing and
|
ground-floor elevation (bounded wait, same ellipsoidal datum) before framing and
|
||||||
warms the cell; after landing, `src/cameraGroundGuard.js` samples the rendered
|
warms the cell; after landing, `src/cameraGroundGuard.js` samples the rendered
|
||||||
surface under the target and the camera once tiles stream in and lifts the eye
|
surface under the target and the camera once tiles stream in and lifts the eye
|
||||||
to 120 m clearance, holding heading and pitch. Navigation ownership changes (Director, tracking, keyboard/UI controls and direct camera flights), layer/app teardown, and pointer/wheel gestures cancel the check and remove its listeners. `scripts/qa-context-arrivals.mjs` types Camp Mabry
|
to 120 m clearance, holding heading and pitch. Navigation ownership changes (Director, tracking, keyboard/UI controls), any other caller moving the camera or starting a flight before a retry, layer/app teardown, and pointer/wheel gestures cancel the check and remove its listeners; the guard compares the pose it started on rather than wrapping camera methods. `scripts/qa-context-arrivals.mjs` types Camp Mabry
|
||||||
and a Denver coordinate and requires at least 60 m clearance.
|
and a Denver coordinate and requires at least 60 m clearance.
|
||||||
|
|
||||||
## Layer panel ownership
|
## Layer panel ownership
|
||||||
@@ -1473,11 +1473,14 @@ remain unchanged.
|
|||||||
|
|
||||||
## Visual effects ownership
|
## Visual effects ownership
|
||||||
|
|
||||||
VisualEffects owns style-stage creation, crossfades, animation scheduling, bloom
|
VisualEffects keeps the records of the styles, their crossfades, bloom and
|
||||||
and sharpen. Display supplies actions and renders settings; effect execution has
|
sharpen, and drives them through the renderer's effects port: it defines each
|
||||||
no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
|
preset there as a style, and the renderer runs the passes and animates the
|
||||||
Stopping the controller revokes animation before asynchronous UI teardown; final
|
animated ones. Display supplies actions and renders settings; effect execution
|
||||||
destruction removes its stages and restores the borrowed bloom configuration.
|
has no DOM dependency. Existing presets and the 500 ms transition remain
|
||||||
|
unchanged. Stopping the controller revokes animation before asynchronous UI
|
||||||
|
teardown; final destruction removes its styles and turns bloom and sharpen off,
|
||||||
|
and the renderer restores the scene's bloom configuration when it goes.
|
||||||
Snow is no longer a visual preset. A share link that names it, in either the
|
Snow is no longer a visual preset. A share link that names it, in either the
|
||||||
long or compact form, opens on Normal and drops the Snow shader parameters. A
|
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,
|
view passed to embed mode or `show_in_gods_eye_view`, and a saved scene shot,
|
||||||
@@ -2396,9 +2399,9 @@ which embeds the MAP_KEY.
|
|||||||
> so a turned-off layer's entities stayed reachable (Data Centers kept 116
|
> so a turned-off layer's entities stayed reachable (Data Centers kept 116
|
||||||
> MiB). `src/sceneCommandRelease.js`, installed in `src/app/tools.js`,
|
> MiB). `src/sceneCommandRelease.js`, installed in `src/app/tools.js`,
|
||||||
> requests one frame through `governorRequestRender` after every layer
|
> requests one frame through `governorRequestRender` after every layer
|
||||||
> disable and then trims those arrays. It reads private Cesium fields;
|
> disable and then trims those arrays. It reads private Cesium fields through
|
||||||
> `src/sceneCommandRelease.test.mjs` pins them and fails if Cesium renames
|
> `src/render/cesium/internals.js`; `src/sceneCommandRelease.test.mjs` pins
|
||||||
> them or changes which passes it truncates.
|
> them and fails if Cesium renames them or changes which passes it truncates.
|
||||||
|
|
||||||
> **2026-08-08 — performance waves 1+2:** the app idles via an explicit render
|
> **2026-08-08 — performance waves 1+2:** the app idles via an explicit render
|
||||||
> governor (`src/renderGovernor.js` — hold/release from every per-frame
|
> governor (`src/renderGovernor.js` — hold/release from every per-frame
|
||||||
@@ -3092,9 +3095,10 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> take over under the Cockpit cap of 60. Loading/capped contacts remain 2D,
|
> take over under the Cockpit cap of 60. Loading/capped contacts remain 2D,
|
||||||
> out-of-range contacts use rotation-free dots, and exit restores map treatment.
|
> out-of-range contacts use rotation-free dots, and exit restores map treatment.
|
||||||
> Its Radio popover exposes compact power,
|
> Its Radio popover exposes compact power,
|
||||||
> transport, station, and volume controls. Cockpit Previous/Next preserves
|
> transport, station, and volume controls. Cockpit Previous/Next changes the
|
||||||
> selection, autoplay, and broadcaster fallback audio without starting the
|
> selection without starting the map-view station flights that compete with
|
||||||
> map-view station flights that compete with the first-person camera. These
|
> the first-person camera; like the Context Previous/Next buttons, it plays
|
||||||
|
> (with broadcaster fallback) only while a stream is already active. These
|
||||||
> are a Cockpit-only accordion:
|
> are a Cockpit-only accordion:
|
||||||
> each static header uses the left accordion's label and divider with a dedicated
|
> each static header uses the left accordion's label and divider with a dedicated
|
||||||
> directional-chevron disclosure button and no full-row hover treatment. Only one
|
> directional-chevron disclosure button and no full-row hover treatment. Only one
|
||||||
@@ -3257,16 +3261,25 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> outlasts its retries, the proxy pauses upstream calls for the `Retry-After`
|
> 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
|
> (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
|
> 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
|
> gets 502, whose `partial` lists the real heights it has (null for the rest)
|
||||||
|
> when it has any, and the page keeps those and uses its geoid fallback only
|
||||||
|
> for the points without one. The same holds when the upstream answers a
|
||||||
|
> point with a null height. Each request
|
||||||
> that was waiting on the upstream when the 429 came back logs one warning;
|
> that was waiting on the upstream when the 429 came back logs one warning;
|
||||||
> requests refused during the pause do not log. Client geoid
|
> 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
|
||||||
> placement continues to use the existing prior until real heights arrive.
|
> placement continues to use the existing prior until real heights arrive.
|
||||||
> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded
|
> - **Overpass cache admission:** `/api/overpass` refuses cross-site POSTs
|
||||||
> POSTs through the shared local-request gate, then limits requests to 300/min
|
> through the keyless same-site gate (reverse-proxy forwarding headers are
|
||||||
> per socket peer and 900/min globally before reading or validating bodies.
|
> admitted, as for `/api/terrain/heights`), then limits requests to 300/min
|
||||||
|
> per peer — IPv6 peers keyed by /64 — and 900/min across all remote peers
|
||||||
|
> before reading or validating bodies. Direct loopback (no forwarding
|
||||||
|
> headers) has its own 300/min window outside that global; a local reverse
|
||||||
|
> proxy's requests carry forwarding headers and are counted as remote, keyed
|
||||||
|
> `proxied:<socket peer>`, so they cannot spend the owner's window. Upstream
|
||||||
|
> quota stays shared, so owner cache misses can still hit 429.
|
||||||
> Tag-filter brackets are scanned once; nested or unmatched brackets return 400.
|
> Tag-filter brackets are scanned once; nested or unmatched brackets return 400.
|
||||||
> It then checks fresh memory, identical in-flight work, and fresh disk entries
|
> It then checks fresh memory, identical in-flight work, and fresh disk entries
|
||||||
> before invoking its separate local 90/min upstream limiter. Cache and
|
> before invoking its separate local 90/min upstream limiter. Cache and
|
||||||
@@ -3453,8 +3466,14 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> celestial ring, annotations SVG, missions frame tick, and traffic).
|
> celestial ring, annotations SVG, missions frame tick, and traffic).
|
||||||
> - **World-label accounting:** All intended label/card migrations use the
|
> - **World-label accounting:** All intended label/card migrations use the
|
||||||
> shared host. The former native `LabelGraphics` exception for cable reference
|
> shared host. The former native `LabelGraphics` exception for cable reference
|
||||||
> labels was retired on 2026-08-18; cable text may render over tile geometry,
|
> labels was retired on 2026-08-18; cable text may render over tile geometry.
|
||||||
> so **zero native world-label creation sites remain**. The mission replay DOM vehicle, awareness
|
> The Directions A/B letters (`directions-markers` source) and data-pack media
|
||||||
|
> labels (a pooled `data-pack-labels:<n>` source per pack resource, through
|
||||||
|
> the pack handle's `label`) moved to the host on 2026-11-04, and
|
||||||
|
> `src/noCesiumLabels.test.mjs` no longer keeps an allow-list, so **zero
|
||||||
|
> native world-label creation sites remain**. A media pack shown without an
|
||||||
|
> `overlayHost` fails to load rather than drawing a native label. The mission
|
||||||
|
> replay DOM vehicle, awareness
|
||||||
> compass, annotation SVG callouts, and host-owned detection isolation target
|
> compass, annotation SVG callouts, and host-owned detection isolation target
|
||||||
> are explicit exceptions; the awareness ring and cockpit contact pips are
|
> are explicit exceptions; the awareness ring and cockpit contact pips are
|
||||||
> non-text out-of-scope surfaces. Annotation callout text, geometry, leaders,
|
> non-text out-of-scope surfaces. Annotation callout text, geometry, leaders,
|
||||||
@@ -3503,7 +3522,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> ambient-label host source. Its getter reads the already-propagated point
|
> ambient-label host source. Its getter reads the already-propagated point
|
||||||
> cache, preserving the 1 Hz fleet epoch and eliminating the former second ISS
|
> cache, preserving the 1 Hz fleet epoch and eliminating the former second ISS
|
||||||
> propagation. Satellite tracking continues to publish exactly one protected
|
> propagation. Satellite tracking continues to publish exactly one protected
|
||||||
> tracked-lane card through `gevLabelModel` and `_trackedDisplayCached`; the
|
> tracked-lane card through its entity record's `labelModel` and `_trackedDisplayCached`; the
|
||||||
> tracked entity is point-only. Tracking ISS suppresses the ambient entry and
|
> tracked entity is point-only. Tracking ISS suppresses the ambient entry and
|
||||||
> untracking restores it, preventing duplicate ISS text. Disable, orbit-text
|
> untracking restores it, preventing duplicate ISS text. Disable, orbit-text
|
||||||
> preference changes, catalog rebuild, and destroy clear/hide the source.
|
> preference changes, catalog rebuild, and destroy clear/hide the source.
|
||||||
@@ -3518,7 +3537,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> - **Phase 5 tracked civil aircraft label:** A tracked flight's Cesium entity
|
> - **Phase 5 tracked civil aircraft label:** A tracked flight's Cesium entity
|
||||||
> is a billboard-only camera target with no native label. The flight source
|
> is a billboard-only camera target with no native label. The flight source
|
||||||
> retains callsign/registration fallback, flight-level/altitude, speed, stale
|
> retains callsign/registration fallback, flight-level/altitude, speed, stale
|
||||||
> state, airline/type, and plausible-route formatting in `gevLabelModel`; the
|
> state, airline/type, and plausible-route formatting in the entity record's `labelModel`; the
|
||||||
> one protected tracked-lane host card renders that complete model. Its
|
> one protected tracked-lane host card renders that complete model. Its
|
||||||
> position getter reads only `_trackedDisplayCached`, the same authoritative
|
> position getter reads only `_trackedDisplayCached`, the same authoritative
|
||||||
> Cartesian already consumed by the tracked visual and camera, and never
|
> Cartesian already consumed by the tracked visual and camera, and never
|
||||||
@@ -3625,8 +3644,11 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> - **Tracked-readout ownership:** `trackedReadout.js` is now a presentation-model
|
> - **Tracked-readout ownership:** `trackedReadout.js` is now a presentation-model
|
||||||
> bridge only; it owns no canvas, projection, post-render listener, layout,
|
> bridge only; it owns no canvas, projection, post-render listener, layout,
|
||||||
> keyhole fade, or paint path. Civilian flights, military flights, satellites,
|
> keyhole fade, or paint path. Civilian flights, military flights, satellites,
|
||||||
> and mapped installations write explicit `gevLabelModel` objects and expose
|
> and mapped installations write explicit `labelModel` objects and
|
||||||
> `gevDisplayPosition` getters backed by their layer-owned frame/display cache.
|
> `displayPosition` getters, backed by their layer-owned frame/display cache,
|
||||||
|
> into Core's entity records (`src/data/entityRecords.js`), never onto the
|
||||||
|
> Cesium entity. A layer's private per-entity state goes in its own store from
|
||||||
|
> `createEntityStore()` (ALPR markers: `src/layers/alpr/markerState.js`).
|
||||||
> Selected AIS vessels continue using the vessel source's protected selected
|
> Selected AIS vessels continue using the vessel source's protected selected
|
||||||
> card, so they do not create a duplicate tracked readout. The host registers
|
> card, so they do not create a duplicate tracked readout. The host registers
|
||||||
> the active readout in the protected tracked lane with a zero ambient quota;
|
> the active readout in the protected tracked lane with a zero ambient quota;
|
||||||
@@ -3990,31 +4012,33 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
|
|||||||
requests use Play; a qualified Play-shaped tool call is normalized to Select so
|
requests use Play; a qualified Play-shaped tool call is normalized to Select so
|
||||||
its criteria cannot be silently ignored.
|
its criteria cannot be silently ignored.
|
||||||
|
|
||||||
| Layer | Source | File | Proxy | Update Interval |
|
| Layer | Source | File | Proxy | Update Interval |
|
||||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||||
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
||||||
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
|
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
|
||||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
||||||
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
||||||
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
||||||
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
||||||
| 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) + Autostrada del Brennero (IT, A22 stills) + 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 |
|
||||||
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
|
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
|
||||||
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||||
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
||||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||||
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
|
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
|
||||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||||
|
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||||
|
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||||
|
|
||||||
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
|
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
|
||||||
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
|
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
|
||||||
@@ -4382,11 +4406,11 @@ the same navigation authority as every other camera destination.
|
|||||||
given; re-arming for the other endpoint reuses that lease rather than asking
|
given; re-arming for the other endpoint reuses that lease rather than asking
|
||||||
for a second claim, and every release names the lease, so a superseded instance
|
for a second claim, and every release names the lease, so a superseded instance
|
||||||
can never free the claim its replacement holds. The claim is held until
|
can never free the claim its replacement holds. The claim is held until
|
||||||
the click that uses it has finished being dispatched — every layer binds its
|
the click that uses it has finished being dispatched: the input dispatcher
|
||||||
own handler to the same canvas and they all run inside one browser event, so a
|
checks the claim once per click, before any subscriber runs, but DOM canvas
|
||||||
claim dropped the instant the endpoint is placed would hand the rest of that
|
listeners (the evidence cards) still see the same browser event afterwards, so
|
||||||
same click to the ambient handlers bound after this one. It is returned on the
|
a claim dropped the instant the endpoint is placed would hand them that click.
|
||||||
next tick after placement, and immediately on cancel, Escape, CLEAR, disable
|
It is returned on the next tick after placement, and immediately on cancel, Escape, CLEAR, disable
|
||||||
and destroy; if another tool already holds it nothing is armed and the row says
|
and destroy; if another tool already holds it nothing is armed and the row says
|
||||||
so. Maneuver-dot selection is an ordinary ambient handler and yields while any
|
so. Maneuver-dot selection is an ordinary ambient handler and yields while any
|
||||||
tool holds the pointer. FLY runs through the UI shell's immediate-navigation
|
tool holds the pointer. FLY runs through the UI shell's immediate-navigation
|
||||||
@@ -4736,10 +4760,12 @@ accepted snapshot, and feed telemetry before re-initialization; monotonic
|
|||||||
ownership tokens plus the session boundary keep late callbacks from a retired
|
ownership tokens plus the session boundary keep late callbacks from a retired
|
||||||
session inert.
|
session inert.
|
||||||
A tuner drag resolves previews against the single accepted snapshot captured at
|
A tuner drag resolves previews against the single accepted snapshot captured at
|
||||||
pointer-down, even while a newer catalog is admitted. Release starts playback
|
pointer-down, even while a newer catalog is admitted. Like Previous/Next,
|
||||||
only when the current same-ID record still exactly matches the frozen
|
release starts playback only if a stream was active when the drag began;
|
||||||
presentation and stream metadata; removal or replacement reports the channel as
|
otherwise it just selects the station. It commits only when the current same-ID
|
||||||
unavailable and never silently retargets the drag. A cold degraded fallback may
|
record still exactly matches the frozen presentation and stream metadata.
|
||||||
|
Removal or replacement reports the channel as unavailable and never silently
|
||||||
|
retargets the drag. A cold degraded fallback may
|
||||||
still populate the directory and globe, but its null accepted generation cannot
|
still populate the directory and globe, but its null accepted generation cannot
|
||||||
populate or begin the tuner.
|
populate or begin the tuner.
|
||||||
|
|
||||||
@@ -4916,7 +4942,9 @@ before the later control succeeds, only its playback handoff is suppressed; its
|
|||||||
result reads enabled state from the manager and audio state from the Radio
|
result reads enabled state from the manager and audio state from the Radio
|
||||||
module. Voice Pause is a playback-only no-op while the layer is disabled; it
|
module. Voice Pause is a playback-only no-op while the layer is disabled; it
|
||||||
never enables Radio and reports a fulfilled-false pause as failure. A
|
never enables Radio and reports a fulfilled-false pause as failure. A
|
||||||
newer-response or user-origin Stop still cancels stale handoff work.
|
newer-response or user-origin Stop still cancels stale handoff work, as do a
|
||||||
|
user Pause, a user Previous/Next that only selects another station, and a tuner
|
||||||
|
release onto a station the directory removed or changed mid-drag.
|
||||||
Only a newer user turn or session teardown aborts the complete active-tool set.
|
Only a newer user turn or session teardown aborts the complete active-tool set.
|
||||||
A cancelled turn cannot publish a late Radio playback request. Dedicated Radio
|
A cancelled turn cannot publish a late Radio playback request. Dedicated Radio
|
||||||
controls and generic layer-visibility commands both forward cancellation.
|
controls and generic layer-visibility commands both forward cancellation.
|
||||||
@@ -5405,8 +5433,11 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
### AI HUD Summary (June 2026)
|
### AI HUD Summary (June 2026)
|
||||||
|
|
||||||
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
|
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
|
||||||
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
|
- Input: place/street/nearby-place labels, enabled layers and `feedProvenance`, with layer names as the user sees them; outside English the narration note and per-layer state ids are left out (`overall` carries the state). No model call without a place, street or nearby-place label: the deterministic line is shown.
|
||||||
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
|
- Language: the HUD sends `locale` (part of the dedupe signature); the proxy keeps a shipped locale, English otherwise. Switching language in place drops the reply and aborts the request in flight, so the next tick asks again.
|
||||||
|
- Reply: `{summary, feedState}` (`text.format` JSON schema, `feedState` a snapshot state or null). A view that is not nominal needs the matching `feedState`. The HUD adds its own translated tag, state first and layer names last (`STALE | … | LIVE FLIGHTS`), so the warning is never clipped. Outside English a reply falls back when it carries a feed-state id, a translated layer's English name or id, only Latin letters in a non-Latin language, or, with every feed healthy, the UNAVAILABLE word. `get_hud_caption` uses the same check and tag order, in English, and returns `feedState`.
|
||||||
|
- The model is asked for at most five words; the proxy keeps up to 80 characters, or 20 for Japanese and Chinese, cut at a word boundary unless the first word is longer. It does not count words, since the model reads a name like Rue de Rivoli as one. Errors and the 5 s timeout fall back to the deterministic line; a camera move during typing shows that line instead of the fragment.
|
||||||
|
- `partial` is a feed state of its own, ranked between `fallback` and `nominal`, so one partial layer, or partial plus nominal, reads partial, not off or nominal (HUD, voice view state, `analyst_query`, `brief`, the agent world packet). Its narration note says coverage is incomplete rather than not live.
|
||||||
- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable.
|
- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable.
|
||||||
|
|
||||||
### Place-search providers
|
### Place-search providers
|
||||||
@@ -5609,9 +5640,9 @@ request viewbox and continues searching without that geographic preference.
|
|||||||
- **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`.
|
- 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 the scene's input dispatcher (`src/input/dispatcher.js`) hands its clicks, moves and double clicks to the draw tool's leased subscriptions only: **every** ambient subscriber — flights, military, CCTV and its calibration gizmo, vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers — is skipped for that event before anything is picked. 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 suppressed 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. The tool takes the dispatcher's `suppressStockClicks()` hold for the session; both actions come back, as the same function objects, when the last hold is released (the imagery box takes the same hold).
|
||||||
- **Enter belongs to whatever has the keyboard.** The finish key is taken only from the canvas and the page body. With a control focused — Clear, a shape button — Enter activates that control, as a keyboard user expects; it does not quietly finish the shape.
|
- **Enter belongs to whatever has the keyboard.** The finish key is taken only from the canvas and the page body. With a control focused — Clear, a shape button — Enter activates that control, as a keyboard user expects; it does not quietly finish the shape.
|
||||||
- Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`.
|
- Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`.
|
||||||
- The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`.
|
- The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`.
|
||||||
@@ -5691,7 +5722,7 @@ pins and share links already supply complete boxes.
|
|||||||
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
|
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
|
||||||
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
|
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
|
||||||
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
|
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
|
||||||
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the first reveal within a bounded wall-clock window before the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the settle frame and then the first reveal frame, each within a 500 ms wall-clock budget that sits inside the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. Only an inconclusive timeout is retried (once); a timeout keeps every angle it measured (the settle frame before a reveal timeout, and the late frame itself, which is read before the deadline check), so a measured wrong angle fails without retry. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
||||||
|
|
||||||
### TR-3B conversion Easter egg (August 2026)
|
### TR-3B conversion Easter egg (August 2026)
|
||||||
|
|
||||||
@@ -5966,6 +5997,26 @@ easier to meet (detection is now on more often), but does not create it.
|
|||||||
above and the DISPLAY-rail entry below (was `OFF`).
|
above and the DISPLAY-rail entry below (was `OFF`).
|
||||||
- Style shader starting params: CRT/NVG/FLIR pixelation `1.2` (just above the native `1.0` floor); thermal/FLIR ships an optional Ironbow "Predator" palette (`palette` uniform, default `0` = accurate grayscale).
|
- Style shader starting params: CRT/NVG/FLIR pixelation `1.2` (just above the native `1.0` floor); thermal/FLIR ships an optional Ironbow "Predator" palette (`palette` uniform, default `0` = accurate grayscale).
|
||||||
|
|
||||||
|
## Renderer contract
|
||||||
|
|
||||||
|
`src/render/contract/` types the renderer contract, version `0`: nine ports
|
||||||
|
(frame, globe, draw, camera, query, input, effects, credits and quality),
|
||||||
|
plus the renderer itself. `PORTS` lists every member at run time, and
|
||||||
|
`contract.test.mjs` keeps that list and the types in step. The application
|
||||||
|
does not yet draw through the contract: it still drives Cesium directly.
|
||||||
|
`src/render/stub/` is a renderer that draws nothing, with the default
|
||||||
|
camera's exact maths. `src/render/conformance/` is the suite: logic checks
|
||||||
|
in Node, plus camera traces recorded from Cesium's own `Camera` into
|
||||||
|
`goldens/camera.json`. It also holds the reference scenes, whose views are
|
||||||
|
checked against `goldens/visual/` on the `/dev/conformance/` page. See
|
||||||
|
[RENDERER-CONTRACT.md](RENDERER-CONTRACT.md).
|
||||||
|
|
||||||
|
`src/geo/` (export `./geo`) is the renderer-neutral maths: WGS84, ECEF and
|
||||||
|
geodetic conversions, surface normals and east-north-up frames, as plain
|
||||||
|
`{x, y, z}` objects. It follows Cesium's formulas step for step, and
|
||||||
|
`geo.test.mjs` checks it against Cesium. The stub renderer's maths is built
|
||||||
|
on it.
|
||||||
|
|
||||||
## Operational Notes
|
## Operational Notes
|
||||||
|
|
||||||
- **Earthquake discs are STATIC geometry.** Every quake is a `CLAMP_TO_GROUND`
|
- **Earthquake discs are STATIC geometry.** Every quake is a `CLAMP_TO_GROUND`
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ registry of source functions. Each receives `{ path, signal, maxBytes }` and
|
|||||||
returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the
|
returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the
|
||||||
signal, bound reads and return the real MIME type. They remain application code,
|
signal, bound reads and return the real MIME type. They remain application code,
|
||||||
not scene document fields. Without registrations, referenced sources fail closed.
|
not scene document fields. Without registrations, referenced sources fail closed.
|
||||||
|
A standalone `SceneDirector` also passes `dataPacks.overlayHost`, the
|
||||||
|
world-overlay host that draws pack world text such as a media pack's label (an
|
||||||
|
object with `setEntries(sourceId, entries)` and `clearSource(sourceId)`);
|
||||||
|
without it a media pack fails to load. `createApplicationTools` passes the
|
||||||
|
application's shared host.
|
||||||
|
|
||||||
The portable session receives format adapters separately. Each adapter receives
|
The portable session receives format adapters separately. Each adapter receives
|
||||||
`{ pack, asset, anchors, signal }` and returns a resource with an idempotent
|
`{ pack, asset, anchors, signal }` and returns a resource with an idempotent
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Renderer contract
|
||||||
|
|
||||||
|
The renderer contract is what the application asks of a 3D renderer. A
|
||||||
|
renderer other than the default Cesium one can draw the same application if
|
||||||
|
it meets the contract. The contract is version `0`. Today the application
|
||||||
|
still drives Cesium directly; later work moves the layers onto the contract
|
||||||
|
one family at a time.
|
||||||
|
|
||||||
|
The package exports three modules:
|
||||||
|
|
||||||
|
| Export | What it is |
|
||||||
|
| ---------------------------------- | ------------------------------------------------------------------------------ |
|
||||||
|
| `gods-eye-view/render/contract` | The types, one `.d.ts` per port, plus the run-time member list and shape check |
|
||||||
|
| `gods-eye-view/render/stub` | A renderer that draws nothing, for tests |
|
||||||
|
| `gods-eye-view/render/conformance` | The conformance suite, reference scenes and visual comparison |
|
||||||
|
|
||||||
|
## Ports
|
||||||
|
|
||||||
|
A renderer is created by a `RendererPlugin` (`{ id, create(options) }`). It
|
||||||
|
exposes nine ports, each typed in its own file in `src/render/contract/`:
|
||||||
|
|
||||||
|
| Port | File | Covers |
|
||||||
|
| --------- | -------------- | ------------------------------------------------------------------------------ |
|
||||||
|
| `frame` | `frame.d.ts` | The render-on-demand loop, frame phases, continuous holds, scene time |
|
||||||
|
| `globe` | `globe.d.ts` | Base (photoreal, terrain, vector, imagery), imagery layers, split, sky |
|
||||||
|
| `draw` | `draw.d.ts` | Draw sets: icons, points, lines, shapes, models, quads, meshes, fields, agents |
|
||||||
|
| `camera` | `camera.d.ts` | Pose, views, flights, `lookAt`, follow, user controls, events, matrices |
|
||||||
|
| `query` | `query.d.ts` | Picks, projection, rays, heights |
|
||||||
|
| `input` | `input.d.ts` | Pointer events with their pick made, leases, stock behaviours |
|
||||||
|
| `effects` | `effects.d.ts` | Screen styles, bloom, sharpen, screen modulation |
|
||||||
|
| `credits` | `credits.d.ts` | Data credits owed |
|
||||||
|
| `quality` | `quality.d.ts` | Quality knobs, device facts, frame signals, benchmark |
|
||||||
|
|
||||||
|
The renderer itself (`renderer.d.ts`) adds capture, pixel reads, settling,
|
||||||
|
resize, context loss and `destroy`. `common.d.ts` holds the shared shapes
|
||||||
|
(ECEF positions, colours, listener semantics) and the capability list.
|
||||||
|
|
||||||
|
The rules that hold everywhere:
|
||||||
|
|
||||||
|
- **Core keeps its own records.** It never reads a renderer to learn its own
|
||||||
|
state, only what the renderer measures: picks, depth, heights and
|
||||||
|
readiness.
|
||||||
|
- **Capabilities.** An optional member (`?`) exists only with the capability
|
||||||
|
its documentation names first. A required member that names a capability
|
||||||
|
always exists, and without the capability it answers null or empty.
|
||||||
|
Quality support is listed by `quality.knobs()`, not by capabilities.
|
||||||
|
- **Heights are physical.** All heights are metres above the ellipsoid,
|
||||||
|
unexaggerated. Only drawing follows exaggerated terrain.
|
||||||
|
- **Listeners** run in subscription order. A listener removed during
|
||||||
|
delivery does not run; one added during delivery runs from the next event.
|
||||||
|
A listener that throws is reported to the renderer's `error` event, or to
|
||||||
|
the platform's `reportError` when nothing listens for errors. The other
|
||||||
|
listeners, and the frame, still run.
|
||||||
|
- **The frame order is fixed:** camera flight → input → camera `changed` →
|
||||||
|
follow → `preUpdate` → move detection → `preRender` → render →
|
||||||
|
`postRender`, the default renderer's own. A camera move made by follow or
|
||||||
|
in `preUpdate` is reported by `changed` on the next frame.
|
||||||
|
- **Leases:** a tool takes the pointer with `claimPointer`. With `grab`, it
|
||||||
|
takes only the presses it grabs on `down`; the rest, and wheel and pinch,
|
||||||
|
stay with the camera and everyone else.
|
||||||
|
|
||||||
|
`src/render/contract/index.js` lists every member of every port in `PORTS`,
|
||||||
|
along with `CAPABILITIES`, `FRAME_STEPS` and `QUALITY_KEYS`.
|
||||||
|
`checkRendererShape(renderer)` names each required member that is missing,
|
||||||
|
and each capability member present without its capability.
|
||||||
|
`contract.test.mjs` type-checks the contract under strict TypeScript, and
|
||||||
|
fails when `PORTS` and the types disagree on a single member.
|
||||||
|
|
||||||
|
## The stub renderer
|
||||||
|
|
||||||
|
`createStubRenderer({ capabilities, viewport, time })` implements every port
|
||||||
|
in memory and draws nothing. It has:
|
||||||
|
|
||||||
|
- the default camera's exact maths: views, `lookAt`, flights with their
|
||||||
|
height arc and easing, `changed` thresholds and follows;
|
||||||
|
- picks against what was drawn, or a scripted table;
|
||||||
|
- scripted heights;
|
||||||
|
- a virtual clock that nothing advances on its own.
|
||||||
|
|
||||||
|
`renderer.testing` drives it with `tick()`, `advance(ms)`, `pointer(type, x, y)`,
|
||||||
|
`scriptPicks`, `setHeights` and `cacheHeight`, and records its calls.
|
||||||
|
A listener that throws with nothing subscribed to `error` goes to
|
||||||
|
`reportError`; Node has none, so the stub rethrows it on a microtask and
|
||||||
|
`node:test` fails the running test. A test that expects a throw subscribes
|
||||||
|
to `error`.
|
||||||
|
The stub passes the whole conformance suite (`src/render/stub/stub.test.mjs`).
|
||||||
|
|
||||||
|
## Conformance suite
|
||||||
|
|
||||||
|
`runConformance(harness)` runs every check against a renderer through the
|
||||||
|
contract alone and reports each one as passed, failed or skipped. It runs in
|
||||||
|
Node and in a browser page. A harness tells the suite how to drive the
|
||||||
|
renderer under test:
|
||||||
|
|
||||||
|
- `create({ viewport, capabilities })`;
|
||||||
|
- `frame(r)`: one frame, 1/60 s on a virtual clock;
|
||||||
|
- `now(r)` and `toEcef(p)`;
|
||||||
|
- `tolerance` and `flightPaths`.
|
||||||
|
|
||||||
|
Optional members widen what is checked: `pointer` for synthetic input,
|
||||||
|
`scriptHeights`, and `assets.model`.
|
||||||
|
|
||||||
|
There are two layers of checks.
|
||||||
|
|
||||||
|
**Logic checks** run in CI. They cover:
|
||||||
|
|
||||||
|
- the shape check;
|
||||||
|
- listener semantics and the frame loop;
|
||||||
|
- draw-set bookkeeping, picks and the `meta` round-trip;
|
||||||
|
- projection, rays and heights;
|
||||||
|
- leases and click picks;
|
||||||
|
- imagery order, sky, styles, credits and quality knobs;
|
||||||
|
- the camera traces.
|
||||||
|
|
||||||
|
**Camera traces.** `cameraScripts.js` lists camera scripts in the contract's
|
||||||
|
terms. `scripts/record-camera-goldens.mjs` runs them on Cesium's own
|
||||||
|
`Camera`, headless, on a virtual clock, and writes
|
||||||
|
`src/render/conformance/goldens/camera.json`. A renderer under test must
|
||||||
|
match each recorded pose:
|
||||||
|
|
||||||
|
- within 1 mm in position, 1e-9 in unit vectors and angles, and 1e-3 px on
|
||||||
|
screen;
|
||||||
|
- with every flight ending within one frame of the recording.
|
||||||
|
|
||||||
|
`src/render/cesium/cameraGoldens.test.mjs` fails when the installed Cesium
|
||||||
|
no longer records the committed traces. After a Cesium upgrade or a script
|
||||||
|
change, run `node scripts/record-camera-goldens.mjs` and review the diff.
|
||||||
|
|
||||||
|
**Visual checks** run in the browser. The reference scenes
|
||||||
|
(`src/render/conformance/scenes.js`) are built from contract calls only:
|
||||||
|
|
||||||
|
- one scene per primitive family;
|
||||||
|
- a city scene;
|
||||||
|
- a calibration path from orbit down to the street.
|
||||||
|
|
||||||
|
The scenes load nothing from the network.
|
||||||
|
|
||||||
|
`npm run dev`, then open `/dev/conformance/`. The page can:
|
||||||
|
|
||||||
|
- **Run logic suite (stub)** runs the logic checks in the browser.
|
||||||
|
- **Record references** draws every scene the chosen renderer supports.
|
||||||
|
It saves each view to `src/render/conformance/goldens/visual/`, through a
|
||||||
|
dev-server-only endpoint.
|
||||||
|
- **Check scenes** draws the scenes again and compares each capture with its
|
||||||
|
reference. Pixels are compared as pixelmatch compares them: by YIQ
|
||||||
|
difference (threshold 0.1), with antialiasing in either image excused. A
|
||||||
|
capture matches when at most 250 pixels are unexcused and at most 2 are
|
||||||
|
solid (a differing pixel whose four neighbours differ too). Between them
|
||||||
|
the two catch a dropped 4 px point, a dropped 2 px line and a 1 px shift
|
||||||
|
of the view, and pass any MSAA setting. A change nowhere 3 px thick (a
|
||||||
|
thin line gone, an item moved a pixel or two) that covers at most 250 px
|
||||||
|
passes. `visual.js` has the measurements.
|
||||||
|
The page shows the capture, the reference and the difference side by side.
|
||||||
|
|
||||||
|
`window.conformance.runScenes()` and `runLogic()` do the same from a script.
|
||||||
|
Browser checks use the chrome-devtools MCP against a page in its own window.
|
||||||
|
|
||||||
|
The Cesium scene renderer (`src/render/cesium/sceneRenderer.js`) draws the
|
||||||
|
icon, point, line, shape, model and city scenes, and the references were
|
||||||
|
recorded with it. It has only the members the scenes need. The quad, mesh,
|
||||||
|
field, agent and orbit scenes have no reference yet. Each family's reference
|
||||||
|
is recorded when the Cesium renderer gains that family.
|
||||||
|
|
||||||
|
Some behaviour is only drawn, so the logic checks cannot see it: course
|
||||||
|
easing (`courseBlendMs`, `courseGlide`), and a hidden row keeping its eased
|
||||||
|
course. The stub keeps no drawn course. These wait for scenes that capture
|
||||||
|
an icon mid-turn. Height queries that count a set
|
||||||
|
(`includeInHeightQueries`) are not checked either: the stub's heights are
|
||||||
|
scripted, so it does not model them. Nor are video frames (a check needs a
|
||||||
|
video), `fetchTile` (the stub fetches nothing) or what clouds and water
|
||||||
|
draw.
|
||||||
Generated
+15
@@ -32,6 +32,7 @@
|
|||||||
"prettier": "3.9.6",
|
"prettier": "3.9.6",
|
||||||
"puppeteer": "^25.16.0",
|
"puppeteer": "^25.16.0",
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.4",
|
||||||
|
"typescript": "5.9.3",
|
||||||
"vite": "^6.4.4",
|
"vite": "^6.4.4",
|
||||||
"vite-plugin-cesium": "^1.2.23",
|
"vite-plugin-cesium": "^1.2.23",
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.0"
|
||||||
@@ -3545,6 +3546,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "8.9.0",
|
"version": "8.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||||
|
|||||||
+9
-1
@@ -79,6 +79,7 @@
|
|||||||
"prettier": "3.9.6",
|
"prettier": "3.9.6",
|
||||||
"puppeteer": "^25.16.0",
|
"puppeteer": "^25.16.0",
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.4",
|
||||||
|
"typescript": "5.9.3",
|
||||||
"vite": "^6.4.4",
|
"vite": "^6.4.4",
|
||||||
"vite-plugin-cesium": "^1.2.23",
|
"vite-plugin-cesium": "^1.2.23",
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.0"
|
||||||
@@ -227,6 +228,9 @@
|
|||||||
"./standalone/catalog": "./src/standalone/catalog.js",
|
"./standalone/catalog": "./src/standalone/catalog.js",
|
||||||
"./application/layers": "./src/app/constructCatalog.js",
|
"./application/layers": "./src/app/constructCatalog.js",
|
||||||
"./application/operations": "./src/app/operations.js",
|
"./application/operations": "./src/app/operations.js",
|
||||||
|
"./data/entity-records": "./src/data/entityRecords.js",
|
||||||
|
"./input": "./src/input/dispatcher.js",
|
||||||
|
"./queries/heights": "./src/queries/heights.js",
|
||||||
"./data/lifecycle": "./src/data/lifecycle.js",
|
"./data/lifecycle": "./src/data/lifecycle.js",
|
||||||
"./ui/navigation": "./src/ui/navigationController.js",
|
"./ui/navigation": "./src/ui/navigationController.js",
|
||||||
"./ui/share-restoration": "./src/ui/shareRestoration.js",
|
"./ui/share-restoration": "./src/ui/shareRestoration.js",
|
||||||
@@ -290,6 +294,10 @@
|
|||||||
"./tools/panel": "./src/tools/panel.js",
|
"./tools/panel": "./src/tools/panel.js",
|
||||||
"./tools/services": "./src/tools/services.js",
|
"./tools/services": "./src/tools/services.js",
|
||||||
"./view": "./src/view/index.js",
|
"./view": "./src/view/index.js",
|
||||||
"./agent/world-packet": "./src/agent/worldPacket.js"
|
"./agent/world-packet": "./src/agent/worldPacket.js",
|
||||||
|
"./geo": "./src/geo/index.js",
|
||||||
|
"./render/contract": "./src/render/contract/index.js",
|
||||||
|
"./render/stub": "./src/render/stub/index.js",
|
||||||
|
"./render/conformance": "./src/render/conformance/index.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import { advanceSpriteFocus } from '../src/data/focusDeemphasis.js';
|
||||||
advanceSpriteFocus,
|
import { aircraftTreatmentAlpha } from '../src/data/aircraftRecession.js';
|
||||||
} from '../src/data/focusDeemphasis.js';
|
import { createIconSet } from '../src/data/iconSet.js';
|
||||||
import {
|
|
||||||
applyAircraftBillboardTreatment,
|
|
||||||
} from '../src/data/aircraftRecession.js';
|
|
||||||
|
|
||||||
if (typeof global.gc !== 'function') {
|
if (typeof global.gc !== 'function') {
|
||||||
throw new Error('focus-allocation-check.mjs requires node --expose-gc');
|
throw new Error('focus-allocation-check.mjs requires node --expose-gc');
|
||||||
@@ -66,17 +63,14 @@ const focusInput = {
|
|||||||
target,
|
target,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseColor = { withAlpha: (alpha) => ({ alpha }) };
|
|
||||||
const billboard = { scale: 1, color: { alpha: 1 } };
|
|
||||||
const aircraftInput = {
|
const aircraftInput = {
|
||||||
billboard,
|
|
||||||
baseScale: 1,
|
|
||||||
baseAlpha: 1,
|
baseAlpha: 1,
|
||||||
baseColor,
|
|
||||||
focusFactor: 1,
|
focusFactor: 1,
|
||||||
cameraDistanceM: 100,
|
cameraDistanceM: 100,
|
||||||
cameraHeightM: 1000,
|
cameraHeightM: 1000,
|
||||||
};
|
};
|
||||||
|
const icons = createIconSet({ capacity: 4 });
|
||||||
|
const row = icons.add('sprite', {});
|
||||||
|
|
||||||
const baseline = bytesPerCall(() => focusInput.nowMs);
|
const baseline = bytesPerCall(() => focusInput.nowMs);
|
||||||
const focus = netOfLoopBaseline(
|
const focus = netOfLoopBaseline(
|
||||||
@@ -84,30 +78,22 @@ const focus = netOfLoopBaseline(
|
|||||||
baseline,
|
baseline,
|
||||||
);
|
);
|
||||||
const aircraft = netOfLoopBaseline(
|
const aircraft = netOfLoopBaseline(
|
||||||
bytesPerCall(() => applyAircraftBillboardTreatment(aircraftInput).alpha),
|
bytesPerCall(() => aircraftTreatmentAlpha(aircraftInput)),
|
||||||
baseline,
|
baseline,
|
||||||
);
|
);
|
||||||
const productionSpriteTick = netOfLoopBaseline(
|
const productionSpriteTick = netOfLoopBaseline(
|
||||||
bytesPerCall(() => {
|
bytesPerCall(() => {
|
||||||
// Match the hot civilian/military sprite path: the caller constructs fresh
|
// Match the hot icon-set focus pass: the caller constructs a fresh option
|
||||||
// option literals, consumes the shared focus result immediately, and then
|
// literal, consumes the shared focus result immediately and writes it to
|
||||||
// constructs the composed aircraft-treatment options for the same sprite.
|
// the row's focus column (the shader composes it with limb recession).
|
||||||
const focusResult = advanceSpriteFocus(sprite, {
|
const focusResult = advanceSpriteFocus(sprite, {
|
||||||
screenPosition: focusInput.screenPosition,
|
screenPosition: focusInput.screenPosition,
|
||||||
cameraDistance: focusInput.cameraDistance,
|
cameraDistance: focusInput.cameraDistance,
|
||||||
nowMs: focusInput.nowMs,
|
nowMs: focusInput.nowMs,
|
||||||
target,
|
target,
|
||||||
});
|
});
|
||||||
const treatment = applyAircraftBillboardTreatment({
|
icons.setFocus(row, focusResult.factor);
|
||||||
billboard,
|
return focusResult.factor;
|
||||||
baseScale: aircraftInput.baseScale,
|
|
||||||
baseAlpha: aircraftInput.baseAlpha,
|
|
||||||
baseColor,
|
|
||||||
focusFactor: focusResult.factor,
|
|
||||||
cameraDistanceM: aircraftInput.cameraDistanceM,
|
|
||||||
cameraHeightM: aircraftInput.cameraHeightM,
|
|
||||||
});
|
|
||||||
return focusResult.factor + treatment.alpha;
|
|
||||||
}),
|
}),
|
||||||
baseline,
|
baseline,
|
||||||
);
|
);
|
||||||
@@ -118,22 +104,24 @@ assert.ok(
|
|||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
aircraft.roundedMedian <= PURE_CALL_BUDGET,
|
aircraft.roundedMedian <= PURE_CALL_BUDGET,
|
||||||
`applyAircraftBillboardTreatment allocated ${aircraft.rawMedian.toFixed(3)} B/call median`,
|
`aircraftTreatmentAlpha allocated ${aircraft.rawMedian.toFixed(3)} B/call median`,
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
productionSpriteTick.roundedMedian <= PRODUCTION_SPRITE_TICK_BUDGET,
|
productionSpriteTick.roundedMedian <= PRODUCTION_SPRITE_TICK_BUDGET,
|
||||||
`production sprite tick allocated ${productionSpriteTick.rawMedian.toFixed(3)} B/call median`,
|
`production sprite tick allocated ${productionSpriteTick.rawMedian.toFixed(3)} B/call median`,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(JSON.stringify({
|
console.log(
|
||||||
callsPerTrial: CALLS,
|
JSON.stringify({
|
||||||
trials: TRIALS,
|
callsPerTrial: CALLS,
|
||||||
budgets: {
|
trials: TRIALS,
|
||||||
pureCall: PURE_CALL_BUDGET,
|
budgets: {
|
||||||
productionSpriteTick: PRODUCTION_SPRITE_TICK_BUDGET,
|
pureCall: PURE_CALL_BUDGET,
|
||||||
},
|
productionSpriteTick: PRODUCTION_SPRITE_TICK_BUDGET,
|
||||||
loopBaseline: baseline,
|
},
|
||||||
advanceSpriteFocus: focus,
|
loopBaseline: baseline,
|
||||||
applyAircraftBillboardTreatment: aircraft,
|
advanceSpriteFocus: focus,
|
||||||
productionSpriteTick,
|
aircraftTreatmentAlpha: aircraft,
|
||||||
}));
|
productionSpriteTick,
|
||||||
|
}),
|
||||||
|
);
|
||||||
+48
-94
@@ -43,6 +43,25 @@
|
|||||||
"scripts/build-admin-packs.mjs",
|
"scripts/build-admin-packs.mjs",
|
||||||
"scripts/qa-admin-outlines.mjs",
|
"scripts/qa-admin-outlines.mjs",
|
||||||
"src/data/inputOwnership.test.mjs",
|
"src/data/inputOwnership.test.mjs",
|
||||||
|
"src/input/dispatcher.test.mjs",
|
||||||
|
"src/input/testDoubles.mjs",
|
||||||
|
"src/input/sensorPick.js",
|
||||||
|
"src/input/dispatcher.js",
|
||||||
|
"src/queries/heights.js",
|
||||||
|
"src/render/cesium/internals.js",
|
||||||
|
"src/render/cesium/cameraInput.js",
|
||||||
|
"src/render/cesium/ctrlWheelZoom.js",
|
||||||
|
"src/render/cesium/input.js",
|
||||||
|
"src/render/cesium/input.test.mjs",
|
||||||
|
"src/render/cesium/cameraInput.test.mjs",
|
||||||
|
"src/render/cesium/cameraControls.js",
|
||||||
|
"src/render/cesium/cameraControls.test.mjs",
|
||||||
|
"src/render/input/router.js",
|
||||||
|
"src/render/input/router.test.mjs",
|
||||||
|
"src/render/cesium/internals.test.mjs",
|
||||||
|
"src/render/cesium/tileRequests.js",
|
||||||
|
"src/render/cesium/tileRequests.test.mjs",
|
||||||
|
"src/queries/heights.test.mjs",
|
||||||
"docs/CODE-BOUNDARIES.md",
|
"docs/CODE-BOUNDARIES.md",
|
||||||
"src/main.js",
|
"src/main.js",
|
||||||
"docs/APPLICATION.md",
|
"docs/APPLICATION.md",
|
||||||
@@ -661,6 +680,12 @@
|
|||||||
"src/services/application.js",
|
"src/services/application.js",
|
||||||
"src/services/requests.js",
|
"src/services/requests.js",
|
||||||
"src/app/scene.js",
|
"src/app/scene.js",
|
||||||
|
"src/render/cesium/appRenderer.test.mjs",
|
||||||
|
"src/render/noCesiumOutsideRender.test.mjs",
|
||||||
|
"src/render/cesiumAllowList.json",
|
||||||
|
"src/geo/geo.test.mjs",
|
||||||
|
"src/geo/celestial.test.mjs",
|
||||||
|
"src/geo/quaternion.test.mjs",
|
||||||
"src/app/controls.js",
|
"src/app/controls.js",
|
||||||
"src/app/data.js",
|
"src/app/data.js",
|
||||||
"src/app/tools.js",
|
"src/app/tools.js",
|
||||||
@@ -1102,6 +1127,7 @@
|
|||||||
"scripts/qa-anime-sky.mjs",
|
"scripts/qa-anime-sky.mjs",
|
||||||
"scripts/qa-sky-model-lighting.mjs",
|
"scripts/qa-sky-model-lighting.mjs",
|
||||||
"server/providers/overpass/requestAdmission.test.mjs",
|
"server/providers/overpass/requestAdmission.test.mjs",
|
||||||
|
"src/tooling/trackArrivalObservation.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/cctvBadges.test.mjs",
|
"src/ui/cctvBadges.test.mjs",
|
||||||
@@ -1110,98 +1136,26 @@
|
|||||||
"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",
|
"server/providers/common/rateLimitKeys.test.mjs",
|
||||||
"src/voice/hudPlaceLabels.test.mjs",
|
"src/render/contract/contract.test.mjs",
|
||||||
"src/voice/panel/preferences.test.mjs",
|
"src/render/contract/common.d.ts",
|
||||||
"src/voice/panel/preferencesView.test.mjs",
|
"src/render/contract/renderer.d.ts",
|
||||||
"src/voice/voiceControlView.test.mjs",
|
"src/render/contract/frame.d.ts",
|
||||||
"src/voice/panel/host.test.mjs",
|
"src/render/contract/globe.d.ts",
|
||||||
"src/voice/panel/manualPanel.test.mjs",
|
"src/render/contract/draw.d.ts",
|
||||||
"src/voice/panel/nativeActivity.test.mjs",
|
"src/render/contract/camera.d.ts",
|
||||||
"src/voice/panel/inactivity.test.mjs",
|
"src/render/contract/query.d.ts",
|
||||||
"src/voice/panel/actionObserver.test.mjs",
|
"src/render/contract/input.d.ts",
|
||||||
"src/voice/realtimeCostPanel.test.mjs",
|
"src/render/contract/effects.d.ts",
|
||||||
"docs/SHARE-LINK-FORMAT.md",
|
"src/render/contract/credits.d.ts",
|
||||||
"src/share/wire.test.mjs",
|
"src/render/contract/quality.d.ts",
|
||||||
"src/share/viewCodec.test.mjs",
|
"src/render/contract/index.d.ts",
|
||||||
"src/share/legacyShareState.test.mjs",
|
"src/render/stub/stub.test.mjs",
|
||||||
"src/testSupport/shareViewFixtures.mjs",
|
"src/render/conformance/scenes.test.mjs",
|
||||||
"src/ui/shareLinks.test.mjs",
|
"src/render/cesium/cameraGoldens.test.mjs",
|
||||||
"src/ui/shareButton.test.mjs",
|
"scripts/record-camera-goldens.mjs",
|
||||||
"src/share/shareLinkPath.test.mjs",
|
"server/standalone/conformance-references.js",
|
||||||
"src/ui/kit/UiRail.test.mjs",
|
"dev/conformance/index.html",
|
||||||
"src/ui/kit/UiSegmented.vue",
|
"dev/conformance/main.js",
|
||||||
"src/ui/kit/UiSegmented.test.mjs",
|
"docs/RENDERER-CONTRACT.md"
|
||||||
"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",
|
|
||||||
"src/sources/adsbFields.test.mjs",
|
|
||||||
"src/sources/phaseTiming.test.mjs",
|
|
||||||
"scripts/build-census-places.mjs",
|
|
||||||
"src/data/placeBoundaries.test.mjs",
|
|
||||||
"src/data/boundaryLabels.test.mjs",
|
|
||||||
"src/data/packGeometry.test.mjs",
|
|
||||||
"src/annotations/placeOutline.test.mjs",
|
|
||||||
"src/annotations/outlineIdentity.test.mjs",
|
|
||||||
"src/annotations/outlineRungs.test.mjs",
|
|
||||||
"src/annotations/outlineRungsFallback.test.mjs",
|
|
||||||
"src/search/photonSetting.test.mjs",
|
|
||||||
"src/sources/nominatimOutlines.test.mjs",
|
|
||||||
"src/sources/openFreeMapOutlines.test.mjs",
|
|
||||||
"src/sources/openFreeMapOutlinesEdges.test.mjs",
|
|
||||||
"src/standalone/layerSources.test.mjs",
|
|
||||||
"src/tooling/nominatimGuardLifecycle.test.mjs",
|
|
||||||
"src/tooling/nominatimLockOwnership.test.mjs",
|
|
||||||
"src/tooling/nominatimOutlineRoute.test.mjs"
|
|
||||||
]
|
]
|
||||||
+2
-1
@@ -479,7 +479,8 @@ function readMarkup(file) {
|
|||||||
|
|
||||||
// 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\//];
|
// The conformance suite's check titles are developer output, not UI.
|
||||||
|
const NOT_UI = [/^src\/tools\//, /^src\/render\/conformance\//];
|
||||||
|
|
||||||
function sourceFiles() {
|
function sourceFiles() {
|
||||||
return trackedFiles(
|
return trackedFiles(
|
||||||
|
|||||||
+815
-29
File diff suppressed because it is too large.
Load diff
@@ -127,7 +127,7 @@ try {
|
|||||||
let animeOn = false;
|
let animeOn = false;
|
||||||
for (let i = 0; i < s.postProcessStages.length; i++) {
|
for (let i = 0; i < s.postProcessStages.length; i++) {
|
||||||
const stage = s.postProcessStages.get(i);
|
const stage = s.postProcessStages.get(i);
|
||||||
if (stage.name === 'godsEyeView_anime')
|
if (stage.name === 'effects_style_anime')
|
||||||
animeOn = stage.enabled && stage.uniforms.intensity > 0.99;
|
animeOn = stage.enabled && stage.uniforms.intensity > 0.99;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1669,7 +1669,7 @@ async function main() {
|
|||||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (let i = 0; i < prims.length; i++) {
|
for (let i = 0; i < prims.length; i++) {
|
||||||
if (prims.get(i) && prims.get(i)._gevViewshed) n += 1;
|
if (window.__godsEyeView.entityRecords.read(prims.get(i))?.viewshedCameraId) n += 1;
|
||||||
}
|
}
|
||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
@@ -1729,7 +1729,7 @@ async function main() {
|
|||||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||||
window.__qaViewshedRefs = [];
|
window.__qaViewshedRefs = [];
|
||||||
for (let i = 0; i < prims.length; i++) {
|
for (let i = 0; i < prims.length; i++) {
|
||||||
if (prims.get(i) && prims.get(i)._gevViewshed)
|
if (window.__godsEyeView.entityRecords.read(prims.get(i))?.viewshedCameraId)
|
||||||
window.__qaViewshedRefs.push(prims.get(i));
|
window.__qaViewshedRefs.push(prims.get(i));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1738,7 +1738,7 @@ async function main() {
|
|||||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||||
const now = [];
|
const now = [];
|
||||||
for (let i = 0; i < prims.length; i++) {
|
for (let i = 0; i < prims.length; i++) {
|
||||||
if (prims.get(i) && prims.get(i)._gevViewshed) now.push(prims.get(i));
|
if (window.__godsEyeView.entityRecords.read(prims.get(i))?.viewshedCameraId) now.push(prims.get(i));
|
||||||
}
|
}
|
||||||
const before = window.__qaViewshedRefs || [];
|
const before = window.__qaViewshedRefs || [];
|
||||||
const sameCount = now.length === before.length;
|
const sameCount = now.length === before.length;
|
||||||
|
|||||||
@@ -763,7 +763,7 @@ try {
|
|||||||
} : null,
|
} : null,
|
||||||
navigation: snapshot?.navigation || null,
|
navigation: snapshot?.navigation || null,
|
||||||
afterId: trackedInfo?.icao24 || null,
|
afterId: trackedInfo?.icao24 || null,
|
||||||
viewerTrackedId: viewer.trackedEntity?.gevTrackedId || null,
|
viewerTrackedId: window.__godsEyeView.entityRecords.read(viewer.trackedEntity)?.trackedId || null,
|
||||||
contextVisible: !document.getElementById('military-awareness-panel')?.hidden,
|
contextVisible: !document.getElementById('military-awareness-panel')?.hidden,
|
||||||
entryAvailableAfterSettlement: Boolean(entry && !entry.hidden),
|
entryAvailableAfterSettlement: Boolean(entry && !entry.hidden),
|
||||||
blockerStillRegistered: dataManager.layers.has(blockerId),
|
blockerStillRegistered: dataManager.layers.has(blockerId),
|
||||||
@@ -1362,7 +1362,7 @@ try {
|
|||||||
contextMode: styleManager._contextControls._contextMode,
|
contextMode: styleManager._contextControls._contextMode,
|
||||||
contextChanging: styleManager._contextControls._contextModeChanging,
|
contextChanging: styleManager._contextControls._contextModeChanging,
|
||||||
subject: context?.subject ? `${context.subject.layerId}:${context.subject.id}` : null,
|
subject: context?.subject ? `${context.subject.layerId}:${context.subject.id}` : null,
|
||||||
tracked: tracker?.gevTrackedId || null,
|
tracked: window.__godsEyeView.entityRecords.read(tracker)?.trackedId || null,
|
||||||
density: styleManager.getDetectionState().densityPct,
|
density: styleManager.getDetectionState().densityPct,
|
||||||
detectionMode: styleManager.getDetectionState().detectionMode,
|
detectionMode: styleManager.getDetectionState().detectionMode,
|
||||||
installations: dataManager.getLayerLifecycleState('military-installations'),
|
installations: dataManager.getLayerLifecycleState('military-installations'),
|
||||||
|
|||||||
@@ -360,7 +360,9 @@ async function contacts() {
|
|||||||
entities: ds?.entities.values.length ?? 0,
|
entities: ds?.entities.values.length ?? 0,
|
||||||
created,
|
created,
|
||||||
tracked:
|
tracked:
|
||||||
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ?? null,
|
window.__godsEyeView.entityRecords.read(
|
||||||
|
window.__godsEyeView.viewer.trackedEntity,
|
||||||
|
)?.trackedId ?? null,
|
||||||
cockpit: document.body.classList.contains('cockpit-mode'),
|
cockpit: document.body.classList.contains('cockpit-mode'),
|
||||||
});
|
});
|
||||||
if (samples.length > 5000) samples.splice(0, 1000);
|
if (samples.length > 5000) samples.splice(0, 1000);
|
||||||
@@ -375,8 +377,9 @@ async function contacts() {
|
|||||||
await page
|
await page
|
||||||
.waitForFunction(
|
.waitForFunction(
|
||||||
() =>
|
() =>
|
||||||
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ===
|
window.__godsEyeView.entityRecords.read(
|
||||||
'flights:aaa051',
|
window.__godsEyeView.viewer.trackedEntity,
|
||||||
|
)?.trackedId === 'flights:aaa051',
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
)
|
)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|||||||
+32
-68
@@ -263,39 +263,16 @@ try {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// The viewer's OWN click actions, captured before the tool ever runs. Both
|
// The viewer's own select and follow, which the input port runs: on before
|
||||||
// must be absent while drawing and back — as the SAME functions — after.
|
// the tool runs, off while drawing, and on again after.
|
||||||
await page.evaluate(() => {
|
const stockActions = async () => {
|
||||||
const Cesium = window.__CESIUM__;
|
const state = await drawState();
|
||||||
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
|
return {
|
||||||
window.__qaStock = {
|
clickOn: state.stockSingleClick,
|
||||||
click:
|
doubleOn: state.stockDoubleClick,
|
||||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
|
|
||||||
double:
|
|
||||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
|
||||||
null,
|
|
||||||
};
|
};
|
||||||
});
|
};
|
||||||
const stockActions = () =>
|
const beforeDraw = await stockActions();
|
||||||
page.evaluate(() => {
|
|
||||||
const Cesium = window.__CESIUM__;
|
|
||||||
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
|
|
||||||
const live = {
|
|
||||||
click:
|
|
||||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
|
|
||||||
double:
|
|
||||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
|
||||||
null,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
clickPresent: Boolean(live.click),
|
|
||||||
doublePresent: Boolean(live.double),
|
|
||||||
clickIsOriginal: live.click === window.__qaStock.click,
|
|
||||||
doubleIsOriginal: live.double === window.__qaStock.double,
|
|
||||||
hadClick: Boolean(window.__qaStock.click),
|
|
||||||
hadDouble: Boolean(window.__qaStock.double),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── enable / disable cycles leave nothing behind ────────────────────────
|
// ── enable / disable cycles leave nothing behind ────────────────────────
|
||||||
const before = await drawState();
|
const before = await drawState();
|
||||||
@@ -318,24 +295,21 @@ try {
|
|||||||
`handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`,
|
`handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── the viewer's own click actions are borrowed, then given back ────────
|
// ── the viewer's own click actions are held off, then given back ───────
|
||||||
await page.click('#draw-toggle');
|
await page.click('#draw-toggle');
|
||||||
const duringDraw = await stockActions();
|
const duringDraw = await stockActions();
|
||||||
check(
|
check(
|
||||||
"Cesium's own selecting click and tracking double-click are both borrowed while drawing",
|
"Cesium's own selecting click and tracking double-click are both off while drawing",
|
||||||
duringDraw.clickPresent === false && duringDraw.doublePresent === false,
|
duringDraw.clickOn === false && duringDraw.doubleOn === false,
|
||||||
`click present ${duringDraw.clickPresent}, double present ${duringDraw.doublePresent} ` +
|
JSON.stringify({ beforeDraw, duringDraw }),
|
||||||
`(viewer had click ${duringDraw.hadClick}, double ${duringDraw.hadDouble})`,
|
|
||||||
);
|
);
|
||||||
await page.click('#draw-toggle');
|
await page.click('#draw-toggle');
|
||||||
const afterDraw = await stockActions();
|
const afterDraw = await stockActions();
|
||||||
check(
|
check(
|
||||||
'and both are restored as the SAME functions, not replacements',
|
'and both are back after',
|
||||||
afterDraw.clickPresent === duringDraw.hadClick &&
|
afterDraw.clickOn === beforeDraw.clickOn &&
|
||||||
afterDraw.doublePresent === duringDraw.hadDouble &&
|
afterDraw.doubleOn === beforeDraw.doubleOn,
|
||||||
afterDraw.clickIsOriginal &&
|
JSON.stringify({ beforeDraw, afterDraw }),
|
||||||
afterDraw.doubleIsOriginal,
|
|
||||||
JSON.stringify(afterDraw),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── a pin, finished with Enter ──────────────────────────────────────────
|
// ── a pin, finished with Enter ──────────────────────────────────────────
|
||||||
@@ -664,19 +638,6 @@ try {
|
|||||||
document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0,
|
document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0,
|
||||||
{ timeout: 10_000 },
|
{ timeout: 10_000 },
|
||||||
);
|
);
|
||||||
// The reload built a new page context: re-capture the viewer's own click
|
|
||||||
// actions so the identity comparisons below still mean something.
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const Cesium = window.__CESIUM__;
|
|
||||||
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
|
|
||||||
window.__qaStock = {
|
|
||||||
click:
|
|
||||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
|
|
||||||
double:
|
|
||||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
|
||||||
null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
await page.evaluate((view) => {
|
await page.evaluate((view) => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
const viewer = window.__godsEyeView.viewer;
|
||||||
viewer.camera.cancelFlight?.();
|
viewer.camera.cancelFlight?.();
|
||||||
@@ -827,24 +788,20 @@ try {
|
|||||||
const wasActive = tool.active;
|
const wasActive = tool.active;
|
||||||
await tool.destroy();
|
await tool.destroy();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
const Cesium = window.__CESIUM__;
|
|
||||||
const viewer = window.__godsEyeView.viewer;
|
const viewer = window.__godsEyeView.viewer;
|
||||||
const stock = viewer.screenSpaceEventHandler;
|
|
||||||
let previews = 0;
|
let previews = 0;
|
||||||
for (let i = 0; i < viewer.dataSources.length; i += 1)
|
for (let i = 0; i < viewer.dataSources.length; i += 1)
|
||||||
if (viewer.dataSources.get(i)?.name === 'gev-draw-preview') previews += 1;
|
if (viewer.dataSources.get(i)?.name === 'gev-draw-preview') previews += 1;
|
||||||
|
const diagnostics = tool.diagnostics();
|
||||||
return {
|
return {
|
||||||
wasActive,
|
wasActive,
|
||||||
diagnostics: tool.diagnostics(),
|
diagnostics,
|
||||||
previews,
|
previews,
|
||||||
dataSources: viewer.dataSources.length,
|
dataSources: viewer.dataSources.length,
|
||||||
handleGone: window.__gevDrawTool === undefined,
|
handleGone: window.__gevDrawTool === undefined,
|
||||||
clickRestored:
|
// The viewer's own select and follow run again.
|
||||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) ===
|
clickRestored: diagnostics.stockSingleClick,
|
||||||
window.__qaStock.click,
|
doubleRestored: diagnostics.stockDoubleClick,
|
||||||
doubleRestored:
|
|
||||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ===
|
|
||||||
window.__qaStock.double,
|
|
||||||
drawingClass: document.body.classList.contains('gev-drawing'),
|
drawingClass: document.body.classList.contains('gev-drawing'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -941,6 +898,11 @@ async function findClickableTarget() {
|
|||||||
const site = await page
|
const site = await page
|
||||||
.evaluate(async () => {
|
.evaluate(async () => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
const viewer = window.__godsEyeView.viewer;
|
||||||
|
// Core registers each local feature's context as `<layer id>:<feature id>`.
|
||||||
|
const isDataCentre = (entity) =>
|
||||||
|
window.__godsEyeView.entityRecords
|
||||||
|
.read(entity)
|
||||||
|
?.contextId?.startsWith('local-datacenters:');
|
||||||
await window.__godsEyeView.dataManager.setEnabled(
|
await window.__godsEyeView.dataManager.setEnabled(
|
||||||
'local-datacenters',
|
'local-datacenters',
|
||||||
true,
|
true,
|
||||||
@@ -960,7 +922,7 @@ async function findClickableTarget() {
|
|||||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||||
for (let i = 0; i < viewer.dataSources.length; i += 1) {
|
for (let i = 0; i < viewer.dataSources.length; i += 1) {
|
||||||
for (const entity of viewer.dataSources.get(i).entities.values) {
|
for (const entity of viewer.dataSources.get(i).entities.values) {
|
||||||
if (entity.__localLayerId !== 'local-datacenters') continue;
|
if (!isDataCentre(entity)) continue;
|
||||||
const point = centreOf(entity);
|
const point = centreOf(entity);
|
||||||
if (!point) continue;
|
if (!point) continue;
|
||||||
const carto = window.Cesium
|
const carto = window.Cesium
|
||||||
@@ -993,8 +955,10 @@ async function findClickableTarget() {
|
|||||||
viewer.scene.requestRender();
|
viewer.scene.requestRender();
|
||||||
}, site);
|
}, site);
|
||||||
await wait(4000);
|
await wait(4000);
|
||||||
const spot = await onScreen(
|
const spot = await onScreen((entity) =>
|
||||||
(entity) => entity.__localLayerId === 'local-datacenters',
|
window.__godsEyeView.entityRecords
|
||||||
|
.read(entity)
|
||||||
|
?.contextId?.startsWith('local-datacenters:'),
|
||||||
);
|
);
|
||||||
return spot ? { ...spot, layer: 'data centre' } : null;
|
return spot ? { ...spot, layer: 'data centre' } : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,8 +169,8 @@ async function observeTrackedHostPaint(page, timeoutMs = 5000) {
|
|||||||
const tracked = viewer?.trackedEntity;
|
const tracked = viewer?.trackedEntity;
|
||||||
const current = {
|
const current = {
|
||||||
elapsedMs: Math.round(performance.now() - startedAt),
|
elapsedMs: Math.round(performance.now() - startedAt),
|
||||||
entryId: tracked?.gevTrackedId || null,
|
entryId: window.__godsEyeView?.entityRecords?.read(tracked)?.trackedId || null,
|
||||||
hasPresentationModel: Boolean(tracked?.gevLabelModel?.title),
|
hasPresentationModel: Boolean(window.__godsEyeView?.entityRecords?.read(tracked)?.labelModel?.title),
|
||||||
candidateCount: diagnostics.candidateCount || 0,
|
candidateCount: diagnostics.candidateCount || 0,
|
||||||
projectedCount: diagnostics.projectedCount || 0,
|
projectedCount: diagnostics.projectedCount || 0,
|
||||||
selectedCount: diagnostics.selectedCount || 0,
|
selectedCount: diagnostics.selectedCount || 0,
|
||||||
@@ -610,7 +610,7 @@ async function main() {
|
|||||||
if (!pool.length) return null;
|
if (!pool.length) return null;
|
||||||
const ent = v.trackedEntity;
|
const ent = v.trackedEntity;
|
||||||
let tracked = null;
|
let tracked = null;
|
||||||
if (ent && typeof ent.gevDisplayPosition === 'function') tracked = ent.gevDisplayPosition();
|
if (ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function') tracked = window.__godsEyeView.entityRecords.read(ent)?.displayPosition();
|
||||||
if (!tracked) return pool[0];
|
if (!tracked) return pool[0];
|
||||||
let best = null, bestD = Infinity;
|
let best = null, bestD = Infinity;
|
||||||
for (const m of pool) {
|
for (const m of pool) {
|
||||||
|
|||||||
@@ -218,9 +218,11 @@ try {
|
|||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
const snap = () => infraIds.map((id) => {
|
const snap = () => infraIds.map((id) => {
|
||||||
const visibleIds = [];
|
const visibleIds = [];
|
||||||
|
// Core registers each local feature's context as `<layer id>:<feature id>`.
|
||||||
for (let i = 0; i < gev.viewer.dataSources.length; i++) {
|
for (let i = 0; i < gev.viewer.dataSources.length; i++) {
|
||||||
for (const entity of gev.viewer.dataSources.get(i).entities.values) {
|
for (const entity of gev.viewer.dataSources.get(i).entities.values) {
|
||||||
if (entity.__localLayerId === id && entity.show) visibleIds.push(String(entity.id));
|
const contextId = gev.entityRecords.read(entity)?.contextId;
|
||||||
|
if (contextId?.startsWith(`${id}:`) && entity.show) visibleIds.push(String(entity.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return visibleIds.sort();
|
return visibleIds.sort();
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ export async function checkInstallationPan(page, dir) {
|
|||||||
'military-installations',
|
'military-installations',
|
||||||
).module;
|
).module;
|
||||||
const centerIds = [];
|
const centerIds = [];
|
||||||
|
// Core registers each local feature's context as `<layer id>:<feature id>`.
|
||||||
for (let i = 0; i < g.viewer.dataSources.length; i++)
|
for (let i = 0; i < g.viewer.dataSources.length; i++)
|
||||||
for (const e of g.viewer.dataSources.get(i).entities.values)
|
for (const e of g.viewer.dataSources.get(i).entities.values)
|
||||||
if (e.__localLayerId === 'local-datacenters') centerIds.push(e.id);
|
if (g.entityRecords.read(e)?.contextId?.startsWith('local-datacenters:'))
|
||||||
|
centerIds.push(e.id);
|
||||||
window.__polishPanFrames = [];
|
window.__polishPanFrames = [];
|
||||||
window.__stopPolishPan = g.viewer.scene.postRender.addEventListener(() => {
|
window.__stopPolishPan = g.viewer.scene.postRender.addEventListener(() => {
|
||||||
const sites = [],
|
const sites = [],
|
||||||
@@ -333,7 +335,8 @@ export async function checkInstallationSelection(
|
|||||||
for (let y = 250; y < 650; y += 75)
|
for (let y = 250; y < 650; y += 75)
|
||||||
for (let x = 400; x < 850; x += 75) {
|
for (let x = 400; x < 850; x += 75) {
|
||||||
const pick = g.viewer.scene.pick({ x, y });
|
const pick = g.viewer.scene.pick({ x, y });
|
||||||
if (!pick?.id?.installationId && !pick?.id?.gevTrackedId)
|
// Nothing picked: no installation marker, outline or tracked contact.
|
||||||
|
if (!pick?.id)
|
||||||
return { x, y };
|
return { x, y };
|
||||||
}
|
}
|
||||||
throw new Error('No empty map point');
|
throw new Error('No empty map point');
|
||||||
|
|||||||
@@ -1277,8 +1277,9 @@ try {
|
|||||||
outlines = 0;
|
outlines = 0;
|
||||||
for (let i = 0; i < viewer.dataSources.length; i++)
|
for (let i = 0; i < viewer.dataSources.length; i++)
|
||||||
for (const entity of viewer.dataSources.get(i).entities.values) {
|
for (const entity of viewer.dataSources.get(i).entities.values) {
|
||||||
if (!ids.has(entity.installationId || entity.id) || !entity.show)
|
// Outline entities are `<record id>:outline:<ring>`.
|
||||||
continue;
|
const recordId = String(entity.id).replace(/:outline:\d+$/, '');
|
||||||
|
if (!ids.has(recordId) || !entity.show) continue;
|
||||||
if (entity.billboard || entity.point) markers++;
|
if (entity.billboard || entity.point) markers++;
|
||||||
if (entity.polyline) outlines++;
|
if (entity.polyline) outlines++;
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-9
@@ -3073,20 +3073,27 @@ async function main() {
|
|||||||
expectedSlot: targetIndex,
|
expectedSlot: targetIndex,
|
||||||
previewPlayDelta: previewPlays - playsBefore,
|
previewPlayDelta: previewPlays - playsBefore,
|
||||||
commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore,
|
commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore,
|
||||||
|
audioState: state.audioState,
|
||||||
|
tuningStatic: state.tuningStatic,
|
||||||
samples,
|
samples,
|
||||||
spread: Math.max(...samples) - Math.min(...samples),
|
spread: Math.max(...samples) - Math.min(...samples),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
check(
|
check(
|
||||||
'pointer preview never plays and release commits the nearest station once without a needle sweep',
|
'from a stopped Radio, pointer release only selects the nearest station, silently and without a needle sweep',
|
||||||
tunerDirectRelease.targetId
|
tunerDirectRelease.targetId
|
||||||
&& tunerDirectRelease.selectedId === tunerDirectRelease.targetId
|
&& tunerDirectRelease.selectedId === tunerDirectRelease.targetId
|
||||||
&& tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot
|
&& tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot
|
||||||
&& tunerDirectRelease.previewPlayDelta === 0
|
&& tunerDirectRelease.previewPlayDelta === 0
|
||||||
&& tunerDirectRelease.commitPlayDelta === 1
|
&& tunerDirectRelease.commitPlayDelta === 0
|
||||||
|
&& tunerDirectRelease.audioState === 'stopped'
|
||||||
|
&& !tunerDirectRelease.tuningStatic
|
||||||
&& tunerDirectRelease.spread < 0.5,
|
&& tunerDirectRelease.spread < 0.5,
|
||||||
JSON.stringify(tunerDirectRelease),
|
JSON.stringify(tunerDirectRelease),
|
||||||
);
|
);
|
||||||
|
// The release plays only if a stream was active when the drag began.
|
||||||
|
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
|
||||||
|
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||||
// Earlier cases scroll and resize the directory. Bring the physical drag
|
// Earlier cases scroll and resize the directory. Bring the physical drag
|
||||||
// target back into its scroller before deriving viewport mouse coordinates.
|
// target back into its scroller before deriving viewport mouse coordinates.
|
||||||
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
|
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
|
||||||
@@ -3494,6 +3501,9 @@ async function main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
// Next left the Radio stopped; a release plays only from an active stream.
|
||||||
|
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
|
||||||
|
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||||
const failedTuner = await page.evaluate(async () => {
|
const failedTuner = await page.evaluate(async () => {
|
||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
const slider = document.getElementById('radio-tuner-slider');
|
const slider = document.getElementById('radio-tuner-slider');
|
||||||
@@ -3692,7 +3702,14 @@ async function main() {
|
|||||||
radio.stopPlayback();
|
radio.stopPlayback();
|
||||||
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
|
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
|
||||||
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
|
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
|
||||||
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
|
// Next plays only while a stream is active, so start from a playing station.
|
||||||
|
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: true, focus: false });
|
||||||
|
});
|
||||||
|
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const gev = window.__godsEyeView;
|
||||||
|
const camera = gev.viewer.camera;
|
||||||
|
const radio = gev.dataManager.layers.get('radio').module;
|
||||||
const pool = gev.styleManager._radioControls._radioTunerPool;
|
const pool = gev.styleManager._radioControls._radioTunerPool;
|
||||||
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
|
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
|
||||||
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
|
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
|
||||||
@@ -3800,7 +3817,7 @@ async function main() {
|
|||||||
id: `qa:${ownerPrefix}:radio-camera-owner`,
|
id: `qa:${ownerPrefix}:radio-camera-owner`,
|
||||||
position: viewer.camera.positionWC.clone(),
|
position: viewer.camera.positionWC.clone(),
|
||||||
});
|
});
|
||||||
owner.gevTrackedId = `${ownerPrefix}:qa-radio-camera-owner`;
|
window.__godsEyeView.entityRecords.write(owner).trackedId = `${ownerPrefix}:qa-radio-camera-owner`;
|
||||||
viewer.trackedEntity = owner;
|
viewer.trackedEntity = owner;
|
||||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
const beforeStationId = radio.getUIState().selected?.id || null;
|
const beforeStationId = radio.getUIState().selected?.id || null;
|
||||||
@@ -3814,7 +3831,7 @@ async function main() {
|
|||||||
afterStationId: radio.getUIState().selected?.id || null,
|
afterStationId: radio.getUIState().selected?.id || null,
|
||||||
flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls,
|
flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls,
|
||||||
trackedIdentityPreserved: viewer.trackedEntity === owner,
|
trackedIdentityPreserved: viewer.trackedEntity === owner,
|
||||||
trackedId: viewer.trackedEntity?.gevTrackedId || null,
|
trackedId: window.__godsEyeView.entityRecords.read(viewer.trackedEntity)?.trackedId || null,
|
||||||
};
|
};
|
||||||
viewer.trackedEntity = undefined;
|
viewer.trackedEntity = undefined;
|
||||||
viewer.entities.remove(owner);
|
viewer.entities.remove(owner);
|
||||||
@@ -3850,11 +3867,11 @@ async function main() {
|
|||||||
id: 'qa:flights:delayed-radio-fallback-owner',
|
id: 'qa:flights:delayed-radio-fallback-owner',
|
||||||
position: viewer.camera.positionWC.clone(),
|
position: viewer.camera.positionWC.clone(),
|
||||||
});
|
});
|
||||||
owner.gevTrackedId = 'flights:qa-delayed-radio-fallback-owner';
|
window.__godsEyeView.entityRecords.write(owner).trackedId = 'flights:qa-delayed-radio-fallback-owner';
|
||||||
viewer.trackedEntity = owner;
|
viewer.trackedEntity = owner;
|
||||||
window.__qaDelayedRadioTrackingOwner = owner;
|
window.__qaDelayedRadioTrackingOwner = owner;
|
||||||
window.__qaRejectRadioPlay();
|
window.__qaRejectRadioPlay();
|
||||||
return owner.gevTrackedId;
|
return window.__godsEyeView.entityRecords.read(owner)?.trackedId;
|
||||||
});
|
});
|
||||||
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||||
const delayedFallbackOwnership = await page.evaluate((beforeFlyToCalls) => {
|
const delayedFallbackOwnership = await page.evaluate((beforeFlyToCalls) => {
|
||||||
@@ -3863,7 +3880,7 @@ async function main() {
|
|||||||
const result = {
|
const result = {
|
||||||
flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls,
|
flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls,
|
||||||
trackedIdentityPreserved: viewer.trackedEntity === owner,
|
trackedIdentityPreserved: viewer.trackedEntity === owner,
|
||||||
trackedId: viewer.trackedEntity?.gevTrackedId || null,
|
trackedId: window.__godsEyeView.entityRecords.read(viewer.trackedEntity)?.trackedId || null,
|
||||||
selectedId: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id || null,
|
selectedId: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id || null,
|
||||||
};
|
};
|
||||||
viewer.trackedEntity = undefined;
|
viewer.trackedEntity = undefined;
|
||||||
@@ -4205,7 +4222,7 @@ async function main() {
|
|||||||
id: 'qa:flights:radio-recenter-stage-owner',
|
id: 'qa:flights:radio-recenter-stage-owner',
|
||||||
position: viewer.camera.positionWC.clone(),
|
position: viewer.camera.positionWC.clone(),
|
||||||
});
|
});
|
||||||
owner.gevTrackedId = 'flights:qa-radio-recenter-stage-owner';
|
window.__godsEyeView.entityRecords.write(owner).trackedId = 'flights:qa-radio-recenter-stage-owner';
|
||||||
viewer.trackedEntity = owner;
|
viewer.trackedEntity = owner;
|
||||||
completeFlight(0);
|
completeFlight(0);
|
||||||
const trackingBetweenStagesCalls = calls.length;
|
const trackingBetweenStagesCalls = calls.length;
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ try {
|
|||||||
result.bloom.intensityPct === 98 &&
|
result.bloom.intensityPct === 98 &&
|
||||||
snapshot.bloom.intensity === 98 &&
|
snapshot.bloom.intensity === 98 &&
|
||||||
manager._visualEffects.bloomIntensity === 98 &&
|
manager._visualEffects.bloomIntensity === 98 &&
|
||||||
manager._bloomStage.enabled
|
manager.viewer.scene.postProcessStages.bloom.enabled
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -183,7 +183,11 @@ try {
|
|||||||
result.sharpen.intensityPct === 61 &&
|
result.sharpen.intensityPct === 61 &&
|
||||||
manager.getVisualState().sharpen.intensity === 61 &&
|
manager.getVisualState().sharpen.intensity === 61 &&
|
||||||
manager._visualEffects.sharpenIntensity === 0.61 &&
|
manager._visualEffects.sharpenIntensity === 0.61 &&
|
||||||
Math.abs(manager._sharpenStage.uniforms.amount - 1.32) < 1e-9
|
Math.abs(
|
||||||
|
manager.viewer.scene.postProcessStages.getStageByName(
|
||||||
|
'effects_sharpen',
|
||||||
|
).uniforms.amount - 1.32,
|
||||||
|
) < 1e-9
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -199,9 +203,10 @@ try {
|
|||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
const effects = manager._visualEffects;
|
const effects = manager._visualEffects;
|
||||||
manager.setStyle('retro');
|
manager.setStyle('retro');
|
||||||
const owned = [...Object.values(effects.stages), effects.sharpenStage];
|
|
||||||
const collection = manager.viewer.scene.postProcessStages;
|
const collection = manager.viewer.scene.postProcessStages;
|
||||||
const previousBloom = effects.previousBloom;
|
const owned = Object.keys(effects.stages).map((name) =>
|
||||||
|
collection.getStageByName(`effects_style_${name}`),
|
||||||
|
);
|
||||||
const pending = manager.dispose();
|
const pending = manager.dispose();
|
||||||
const stoppedBeforeAwait =
|
const stoppedBeforeAwait =
|
||||||
effects.stopped &&
|
effects.stopped &&
|
||||||
@@ -210,12 +215,15 @@ try {
|
|||||||
await pending;
|
await pending;
|
||||||
return {
|
return {
|
||||||
stoppedBeforeAwait,
|
stoppedBeforeAwait,
|
||||||
removed: owned.every((stage) => !collection.contains(stage)),
|
removed:
|
||||||
restored:
|
owned.every(Boolean) &&
|
||||||
effects.bloomStage.enabled === previousBloom.enabled &&
|
Object.keys(effects.stages).every(
|
||||||
Object.entries(previousBloom.uniforms).every(
|
(name) => !collection.getStageByName(`effects_style_${name}`),
|
||||||
([name, value]) => effects.bloomStage.uniforms[name] === value,
|
|
||||||
),
|
),
|
||||||
|
// The renderer gives the scene's bloom back when it goes.
|
||||||
|
restored:
|
||||||
|
!collection.bloom.enabled &&
|
||||||
|
!collection.getStageByName('effects_sharpen')?.enabled,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
check(
|
check(
|
||||||
@@ -223,7 +231,7 @@ try {
|
|||||||
teardown.stoppedBeforeAwait,
|
teardown.stoppedBeforeAwait,
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'UI teardown removes owned stages and restores borrowed bloom',
|
'UI teardown removes its styles and turns bloom and sharpen off',
|
||||||
teardown.removed && teardown.restored,
|
teardown.removed && teardown.restored,
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ try {
|
|||||||
manager._bloomSlider.focus();
|
manager._bloomSlider.focus();
|
||||||
return {
|
return {
|
||||||
value: Number(manager._bloomSlider.value),
|
value: Number(manager._bloomSlider.value),
|
||||||
contrast: manager._bloomStage.uniforms.contrast,
|
contrast: manager.viewer.scene.postProcessStages.bloom.uniforms.contrast,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
await page.keyboard.press('ArrowRight');
|
await page.keyboard.press('ArrowRight');
|
||||||
@@ -160,7 +160,8 @@ try {
|
|||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
return (
|
return (
|
||||||
Number(manager._bloomSlider.value) > before.value &&
|
Number(manager._bloomSlider.value) > before.value &&
|
||||||
manager._bloomStage.uniforms.contrast < before.contrast
|
manager.viewer.scene.postProcessStages.bloom.uniforms.contrast <
|
||||||
|
before.contrast
|
||||||
);
|
);
|
||||||
}, displayBefore),
|
}, displayBefore),
|
||||||
);
|
);
|
||||||
@@ -267,13 +268,15 @@ try {
|
|||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
manager._displayBindings._detachDisplayControls();
|
manager._displayBindings._detachDisplayControls();
|
||||||
const before = manager.bloomEnabled;
|
const before = manager.bloomEnabled;
|
||||||
const contrast = manager._bloomStage.uniforms.contrast;
|
const contrast =
|
||||||
|
manager.viewer.scene.postProcessStages.bloom.uniforms.contrast;
|
||||||
manager._bloomBtn.click();
|
manager._bloomBtn.click();
|
||||||
manager._bloomSlider.value = '15';
|
manager._bloomSlider.value = '15';
|
||||||
manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true }));
|
manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
return (
|
return (
|
||||||
manager.bloomEnabled === before &&
|
manager.bloomEnabled === before &&
|
||||||
manager._bloomStage.uniforms.contrast === contrast
|
manager.viewer.scene.postProcessStages.bloom.uniforms.contrast ===
|
||||||
|
contrast
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Writes the reference camera traces the conformance suite compares a
|
||||||
|
// renderer against: src/render/conformance/goldens/camera.json. Run it after
|
||||||
|
// a Cesium upgrade or a change to the camera scripts, and review the diff.
|
||||||
|
//
|
||||||
|
// node scripts/record-camera-goldens.mjs [--check]
|
||||||
|
//
|
||||||
|
// With --check it writes nothing and exits 1 when the file is out of date.
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { recordCameraGoldens } from '../src/render/cesium/cameraGoldens.js';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const OUT = path.join(ROOT, 'src/render/conformance/goldens/camera.json');
|
||||||
|
|
||||||
|
const text = `${JSON.stringify(await recordCameraGoldens(), null, 2)}\n`;
|
||||||
|
if (process.argv.includes('--check')) {
|
||||||
|
const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : '';
|
||||||
|
if (current !== text) {
|
||||||
|
console.error(`${path.relative(ROOT, OUT)} is out of date`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fs.mkdirSync(path.dirname(OUT), { recursive: true });
|
||||||
|
fs.writeFileSync(OUT, text);
|
||||||
|
console.log(`wrote ${path.relative(ROOT, OUT)}`);
|
||||||
|
}
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
||||||
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||||
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
||||||
|
|
||||||
@@ -701,7 +701,7 @@ async function main() {
|
|||||||
const tracked = manager?.layers?.get('flights')?.module?.getTrackedInfo?.();
|
const tracked = manager?.layers?.get('flights')?.module?.getTrackedInfo?.();
|
||||||
return manager?.isEnabled?.('flights')
|
return manager?.isEnabled?.('flights')
|
||||||
&& tracked?.icao24 === 'aaa001'
|
&& tracked?.icao24 === 'aaa001'
|
||||||
&& window.__godsEyeView.viewer.trackedEntity?.gevTrackedId === 'flights:aaa001';
|
&& window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId === 'flights:aaa001';
|
||||||
},
|
},
|
||||||
{ timeout: 30000, polling: 100 },
|
{ timeout: 30000, polling: 100 },
|
||||||
);
|
);
|
||||||
@@ -712,7 +712,7 @@ async function main() {
|
|||||||
flightsEnabled: manager.isEnabled('flights'),
|
flightsEnabled: manager.isEnabled('flights'),
|
||||||
trackedId: flights.getTrackedInfo()?.icao24 || null,
|
trackedId: flights.getTrackedInfo()?.icao24 || null,
|
||||||
durableId: flights.getParams()?.selectedFlightsTrackingId || null,
|
durableId: flights.getParams()?.selectedFlightsTrackingId || null,
|
||||||
viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null,
|
viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
record(
|
record(
|
||||||
@@ -742,7 +742,7 @@ async function main() {
|
|||||||
const tracked = manager?.layers?.get('flights')?.module?.getTrackedInfo?.();
|
const tracked = manager?.layers?.get('flights')?.module?.getTrackedInfo?.();
|
||||||
return manager?.isEnabled?.('flights')
|
return manager?.isEnabled?.('flights')
|
||||||
&& tracked?.icao24 === 'aaa001'
|
&& tracked?.icao24 === 'aaa001'
|
||||||
&& window.__godsEyeView.viewer.trackedEntity?.gevTrackedId === 'flights:aaa001';
|
&& window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId === 'flights:aaa001';
|
||||||
},
|
},
|
||||||
{ timeout: 30000, polling: 100 },
|
{ timeout: 30000, polling: 100 },
|
||||||
);
|
);
|
||||||
@@ -753,7 +753,7 @@ async function main() {
|
|||||||
flightsEnabled: manager.isEnabled('flights'),
|
flightsEnabled: manager.isEnabled('flights'),
|
||||||
trackedId: flights.getTrackedInfo()?.icao24 || null,
|
trackedId: flights.getTrackedInfo()?.icao24 || null,
|
||||||
durableId: flights.getParams()?.selectedFlightsTrackingId || null,
|
durableId: flights.getParams()?.selectedFlightsTrackingId || null,
|
||||||
viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null,
|
viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
record(
|
record(
|
||||||
@@ -815,7 +815,7 @@ async function main() {
|
|||||||
const latchedShare = await evalPage(() => ({
|
const latchedShare = await evalPage(() => ({
|
||||||
tracked: window.__godsEyeView.dataManager.layers
|
tracked: window.__godsEyeView.dataManager.layers
|
||||||
.get('flights').module.getTrackedInfo()?.icao24 || null,
|
.get('flights').module.getTrackedInfo()?.icao24 || null,
|
||||||
viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null,
|
viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null,
|
||||||
}));
|
}));
|
||||||
record(
|
record(
|
||||||
'share-v2: the pending subject latches on when it arrives on a later poll',
|
'share-v2: the pending subject latches on when it arrives on a later poll',
|
||||||
@@ -1380,7 +1380,7 @@ async function main() {
|
|||||||
if (exactPickModel) return exactPickModel;
|
if (exactPickModel) return exactPickModel;
|
||||||
const ent = window.__godsEyeView.viewer.trackedEntity;
|
const ent = window.__godsEyeView.viewer.trackedEntity;
|
||||||
let tracked = null;
|
let tracked = null;
|
||||||
if (ent && typeof ent.gevDisplayPosition === 'function') tracked = ent.gevDisplayPosition();
|
if (ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function') tracked = window.__godsEyeView.entityRecords.read(ent)?.displayPosition();
|
||||||
if (!tracked) return pool[0];
|
if (!tracked) return pool[0];
|
||||||
let best = null, bestD = Infinity;
|
let best = null, bestD = Infinity;
|
||||||
for (const m of pool) {
|
for (const m of pool) {
|
||||||
@@ -1427,7 +1427,7 @@ async function main() {
|
|||||||
const dm = window.__godsEyeView.dataManager;
|
const dm = window.__godsEyeView.dataManager;
|
||||||
const fl = dm.layers.get('flights').module;
|
const fl = dm.layers.get('flights').module;
|
||||||
const ent = window.__godsEyeView.viewer.trackedEntity;
|
const ent = window.__godsEyeView.viewer.trackedEntity;
|
||||||
const disp = ent && typeof ent.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null;
|
const disp = ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null;
|
||||||
const dets = fl.getDetectableObjects({ maxCount: 1000 });
|
const dets = fl.getDetectableObjects({ maxCount: 1000 });
|
||||||
const me = dets.find((d) => d.id === icao || d.id === (fl.getTrackedInfo() && fl.getTrackedInfo().callsign));
|
const me = dets.find((d) => d.id === icao || d.id === (fl.getTrackedInfo() && fl.getTrackedInfo().callsign));
|
||||||
if (!disp || !me || !me.position) return null;
|
if (!disp || !me || !me.position) return null;
|
||||||
@@ -1656,7 +1656,7 @@ async function main() {
|
|||||||
const ti = fl.getTrackedInfo();
|
const ti = fl.getTrackedInfo();
|
||||||
if (!ti) return null;
|
if (!ti) return null;
|
||||||
const ent = v.trackedEntity;
|
const ent = v.trackedEntity;
|
||||||
const disp = ent && typeof ent.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null;
|
const disp = ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null;
|
||||||
const pos = disp || ti.position;
|
const pos = disp || ti.position;
|
||||||
if (!pos) return null;
|
if (!pos) return null;
|
||||||
const center = v.scene.cartesianToCanvasCoordinates(pos);
|
const center = v.scene.cartesianToCanvasCoordinates(pos);
|
||||||
@@ -1908,7 +1908,7 @@ async function main() {
|
|||||||
// presentation model (entities are asserted native-label-free);
|
// presentation model (entities are asserted native-label-free);
|
||||||
// the native read stays as a fallback so this probe still fails
|
// the native read stays as a fallback so this probe still fails
|
||||||
// loudly if BOTH surfaces are ever absent.
|
// loudly if BOTH surfaces are ever absent.
|
||||||
const model = ent.gevLabelModel;
|
const model = window.__godsEyeView.entityRecords.read(ent)?.labelModel;
|
||||||
if (model) return [model.title, ...(model.details || [])].filter(Boolean).join('\n');
|
if (model) return [model.title, ...(model.details || [])].filter(Boolean).join('\n');
|
||||||
if (!ent.label || !ent.label.text) return null;
|
if (!ent.label || !ent.label.text) return null;
|
||||||
const t = ent.label.text;
|
const t = ent.label.text;
|
||||||
@@ -2553,7 +2553,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WELD (tracked): the tracked CARD anchors to the model you can see.
|
// WELD (tracked): the tracked CARD anchors to the model you can see.
|
||||||
// `gevVisualPosition` is a SEPARATE accessor from `gevDisplayPosition` on
|
// the record's `visualPosition` is a SEPARATE accessor from its `displayPosition` on
|
||||||
// purpose — the latter carries the follow-camera anti-jitter contract and
|
// purpose — the latter carries the follow-camera anti-jitter contract and
|
||||||
// must keep returning the cached dead-reckoned value the camera settled
|
// must keep returning the cached dead-reckoned value the camera settled
|
||||||
// on. So this asserts both halves: the visual accessor is welded to the
|
// on. So this asserts both halves: the visual accessor is welded to the
|
||||||
@@ -2580,10 +2580,10 @@ async function main() {
|
|||||||
const at = (p) => (p ? Math.hypot(
|
const at = (p) => (p ? Math.hypot(
|
||||||
p.x - anchor.x, p.y - anchor.y, p.z - anchor.z,
|
p.x - anchor.x, p.y - anchor.y, p.z - anchor.z,
|
||||||
) : null);
|
) : null);
|
||||||
const visual = typeof ent?.gevVisualPosition === 'function' ? ent.gevVisualPosition() : null;
|
const visual = typeof window.__godsEyeView.entityRecords.read(ent)?.visualPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.visualPosition() : null;
|
||||||
const display = typeof ent?.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null;
|
const display = typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null;
|
||||||
return {
|
return {
|
||||||
hasVisualAccessor: typeof ent?.gevVisualPosition === 'function',
|
hasVisualAccessor: typeof window.__godsEyeView.entityRecords.read(ent)?.visualPosition === 'function',
|
||||||
visualDelta: at(visual),
|
visualDelta: at(visual),
|
||||||
displayDelta: at(display),
|
displayDelta: at(display),
|
||||||
};
|
};
|
||||||
@@ -2599,7 +2599,7 @@ async function main() {
|
|||||||
// anti-jitter accessor has been repurposed and the follow camera is at
|
// anti-jitter accessor has been repurposed and the follow camera is at
|
||||||
// risk. Skipped (not failed) when the DR cache is momentarily invalid.
|
// risk. Skipped (not failed) when the DR cache is momentarily invalid.
|
||||||
const displayDelta = trackedWeld.displayDelta;
|
const displayDelta = trackedWeld.displayDelta;
|
||||||
record('weld: gevDisplayPosition still reports the dead-reckoned position (anti-jitter intact)',
|
record('weld: the record displayPosition still reports the dead-reckoned position (anti-jitter intact)',
|
||||||
!Number.isFinite(displayDelta) || displayDelta > ACCESSOR_SEPARATION_EPSILON_M,
|
!Number.isFinite(displayDelta) || displayDelta > ACCESSOR_SEPARATION_EPSILON_M,
|
||||||
Number.isFinite(displayDelta)
|
Number.isFinite(displayDelta)
|
||||||
? `displayΔ=${displayDelta.toFixed(3)} m (must stay > ${ACCESSOR_SEPARATION_EPSILON_M} m — the ground-snap split)`
|
? `displayΔ=${displayDelta.toFixed(3)} m (must stay > ${ACCESSOR_SEPARATION_EPSILON_M} m — the ground-snap split)`
|
||||||
@@ -2698,16 +2698,18 @@ async function main() {
|
|||||||
// Staged deterministically: park the camera nadir over the synthetic
|
// Staged deterministically: park the camera nadir over the synthetic
|
||||||
// cluster, let rotations settle, TAMPER a billboard's rotation, then
|
// cluster, let rotations settle, TAMPER a billboard's rotation, then
|
||||||
// trigger each path with the camera idle. The fleet runs every 80 ms,
|
// trigger each path with the camera idle. The fleet runs every 80 ms,
|
||||||
// independently of rendered FPS. Observe the first completed settle and
|
// independently of rendered FPS. Observe the first completed settle frame
|
||||||
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
|
// within 500 ms of forcing moveEnd and the first reveal frame within
|
||||||
// rotation refresh can repair an incorrect first reveal.
|
// 500 ms of that settle frame: each budget sits inside the 1 s idle
|
||||||
|
// rotation refresh that could otherwise repair an incorrect first reveal,
|
||||||
|
// and a ~300 ms headless SwiftShader frame fits inside each of them.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
|
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
|
||||||
const arrival = await evalPage(async () => {
|
const arrival = await evalPage(async () => {
|
||||||
const v = window.__godsEyeView.viewer;
|
const v = window.__godsEyeView.viewer;
|
||||||
const dm = window.__godsEyeView.dataManager;
|
const dm = window.__godsEyeView.dataManager;
|
||||||
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
|
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
|
||||||
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
|
const { observeArrivalRotationWithRetry } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
|
||||||
// 3D models OFF for this phase: a model-handed-off billboard is hidden
|
// 3D models OFF for this phase: a model-handed-off billboard is hidden
|
||||||
// and skips rotation updates entirely — the probes need live billboards
|
// and skips rotation updates entirely — the probes need live billboards
|
||||||
// (this is also the app's default state the field report came from).
|
// (this is also the app's default state the field report came from).
|
||||||
@@ -2769,7 +2771,23 @@ async function main() {
|
|||||||
const bb = findBB(id);
|
const bb = findBB(id);
|
||||||
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
|
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
|
||||||
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
|
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
|
||||||
const observed = await observeArrivalRotation({
|
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
||||||
|
// A wall-clock timeout is inconclusive, not a wrong angle: the frame
|
||||||
|
// landed where the 1 s idle refresh could already have repaired it
|
||||||
|
// (seen once on headless SwiftShader: a 926 ms first frame). Such an
|
||||||
|
// observation gets one more attempt; one that measured a wrong angle —
|
||||||
|
// a wrong first reveal, or a wrong settle frame before a reveal
|
||||||
|
// timeout — never does.
|
||||||
|
const observed = await observeArrivalRotationWithRetry({
|
||||||
|
async prepare() {
|
||||||
|
// Freshen both layers' rotation passes right before the probe, so
|
||||||
|
// the settle frame is measured against a pass that just ran rather
|
||||||
|
// than one left over from the previous probe — with the camera
|
||||||
|
// idle, a layer otherwise runs a pass only every
|
||||||
|
// ROTATION_REFRESH_MS.
|
||||||
|
v.camera.moveEnd.raiseEvent();
|
||||||
|
await nextFrames(1);
|
||||||
|
},
|
||||||
scene: v.scene,
|
scene: v.scene,
|
||||||
startMoveEnd() {
|
startMoveEnd() {
|
||||||
// Only moveEnd can repair the tampered angle at the idle pose.
|
// Only moveEnd can repair the tampered angle at the idle pose.
|
||||||
@@ -2790,7 +2808,6 @@ async function main() {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
|
||||||
return { r0, ...observed, hasModel };
|
return { r0, ...observed, hasModel };
|
||||||
};
|
};
|
||||||
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
|
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
|
||||||
@@ -2800,8 +2817,8 @@ async function main() {
|
|||||||
military: await probe('bbb101', militaryCourse),
|
military: await probe('bbb101', militaryCourse),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
|
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; ${arrivalAngleViolated(a) ? 'angle violation (not retried); ' : ''}${Number.isFinite(a.dMoveEnd) ? `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° ` : ''}${Number.isFinite(a.dReveal) ? `reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° ` : ''}elapsed=${Number(a.elapsedMs).toFixed(1)} ms attempts=${a.attempts}`
|
||||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`);
|
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms attempts=${a.attempts} hasModel=${a.hasModel}`);
|
||||||
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
|
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
|
||||||
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||||
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
|
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isIPv6 } from 'node:net';
|
||||||
import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
||||||
export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
||||||
|
|
||||||
@@ -60,3 +61,46 @@ export function makeCostRateLimiter(envValue, defaultPerMin) {
|
|||||||
export function clientKey(req) {
|
export function clientKey(req) {
|
||||||
return String(req.socket?.remoteAddress || 'local');
|
return String(req.socket?.remoteAddress || 'local');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IPV4_MAPPED_RE = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a socket peer address is this machine: IPv4 loopback in any of its
|
||||||
|
* forms (`127.0.0.0/8`, including the IPv4-mapped `::ffff:127.x` a dual-stack
|
||||||
|
* listener reports) or the IPv6 loopback. Non-address keys are not loopback.
|
||||||
|
* @param {string} address - A `clientKey` value.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isLoopbackPeer(address) {
|
||||||
|
const raw = String(address || '');
|
||||||
|
const v4 = IPV4_MAPPED_RE.exec(raw)?.[1] ?? raw;
|
||||||
|
return v4.startsWith('127.') || raw === '::1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate-limit key that survives address rotation, for limiters whose global
|
||||||
|
* backstop everyone shares. IPv6 privacy extensions hand one host a fresh
|
||||||
|
* address at will inside its /64, so keying by the full address lets a single
|
||||||
|
* host mint per-peer quota until the global window closes for everyone else.
|
||||||
|
* IPv6 peers are keyed by their /64 prefix instead, and an IPv4-mapped
|
||||||
|
* address folds to its IPv4 form so one host cannot hold two buckets. Any
|
||||||
|
* other value (IPv4, or the `local` fallback) passes through unchanged.
|
||||||
|
* @param {import('node:http').IncomingMessage} req
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function clientNetworkKey(req) {
|
||||||
|
const address = clientKey(req);
|
||||||
|
const mapped = IPV4_MAPPED_RE.exec(address);
|
||||||
|
if (mapped) return mapped[1];
|
||||||
|
const bare = address.replace(/%.*$/, ''); // link-local zone id
|
||||||
|
if (!isIPv6(bare)) return address;
|
||||||
|
const [head, tail = ''] = bare.split('::');
|
||||||
|
const headParts = head ? head.split(':') : [];
|
||||||
|
const tailParts = tail ? tail.split(':') : [];
|
||||||
|
const missing = Math.max(0, 8 - headParts.length - tailParts.length);
|
||||||
|
const hextets = [...headParts, ...Array(missing).fill('0'), ...tailParts];
|
||||||
|
return `${hextets
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((h) => h.toLowerCase().replace(/^0+(?=.)/, ''))
|
||||||
|
.join(':')}::/64`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { clientNetworkKey, isLoopbackPeer } from './rate-limit.js';
|
||||||
|
|
||||||
|
const req = (remoteAddress) => ({ socket: { remoteAddress } });
|
||||||
|
|
||||||
|
test('clientNetworkKey keys IPv6 peers by /64 and folds IPv4-mapped peers', () => {
|
||||||
|
assert.equal(clientNetworkKey(req('2001:db8:1:2::1')), '2001:db8:1:2::/64');
|
||||||
|
assert.equal(
|
||||||
|
clientNetworkKey(req('2001:0db8:0001:0002:abcd:ef01:2345:6789')),
|
||||||
|
'2001:db8:1:2::/64',
|
||||||
|
);
|
||||||
|
assert.equal(clientNetworkKey(req('2001:DB8::1')), '2001:db8:0:0::/64');
|
||||||
|
assert.equal(clientNetworkKey(req('fe80::1%en0')), 'fe80:0:0:0::/64');
|
||||||
|
assert.equal(clientNetworkKey(req('::1')), '0:0:0:0::/64');
|
||||||
|
assert.equal(clientNetworkKey(req('::ffff:10.0.0.5')), '10.0.0.5');
|
||||||
|
assert.equal(clientNetworkKey(req('10.0.0.5')), '10.0.0.5');
|
||||||
|
assert.equal(clientNetworkKey(req(undefined)), 'local');
|
||||||
|
assert.equal(clientNetworkKey(req('not-an-address')), 'not-an-address');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isLoopbackPeer recognizes every loopback form and nothing else', () => {
|
||||||
|
for (const address of ['127.0.0.1', '127.9.9.9', '::1', '::ffff:127.0.0.1']) {
|
||||||
|
assert.equal(isLoopbackPeer(address), true, address);
|
||||||
|
}
|
||||||
|
for (const address of [
|
||||||
|
'10.0.0.1',
|
||||||
|
'::ffff:10.0.0.1',
|
||||||
|
'2001:db8::1',
|
||||||
|
'::2',
|
||||||
|
'local',
|
||||||
|
'',
|
||||||
|
undefined,
|
||||||
|
]) {
|
||||||
|
assert.equal(isLoopbackPeer(address), false, String(address));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -58,7 +58,7 @@ function forwardedProtocol(req) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Cross-site gate for keyless routes the map needs in order to render
|
* Cross-site gate for keyless routes the map needs in order to render
|
||||||
* (`/api/terrain/heights`). It refuses the same cross-site browser shapes as
|
* (`/api/terrain/heights`, `/api/overpass`). It refuses the same cross-site browser shapes as
|
||||||
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
|
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
|
||||||
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
|
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
|
||||||
* those protect credentials and spend, which a keyless route has neither of,
|
* those protect credentials and spend, which a keyless route has neither of,
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
|
import { SNAPSHOT_FEED_STATES } from '../../../src/data/layerSnapshot.js';
|
||||||
import {
|
import {
|
||||||
HUD_SUMMARY_INSTRUCTIONS,
|
HUD_SUMMARY_MAX_CHARS,
|
||||||
|
HUD_SUMMARY_MAX_UNSPACED_CHARS,
|
||||||
|
HUD_SUMMARY_RESPONSE_FORMAT,
|
||||||
|
hudSummaryInput,
|
||||||
|
hudSummaryInstructions,
|
||||||
|
hudSummaryIsUnspaced,
|
||||||
|
hudSummaryLocale,
|
||||||
keylessHudSummaryResponse,
|
keylessHudSummaryResponse,
|
||||||
} from '../../../src/hudSummaryResponse.js';
|
} from '../../../src/hudSummaryResponse.js';
|
||||||
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
|
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||||
@@ -18,14 +25,58 @@ function extractOpenAiResponseText(data) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toFiveWordHudSummary(value) {
|
/** Parse the summary and normalize the canonical feed state. */
|
||||||
return String(value || '')
|
function parseHudSummaryReply(text, locale) {
|
||||||
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
let reply = null;
|
||||||
.trim()
|
try {
|
||||||
|
reply = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
/* not JSON: no summary */
|
||||||
|
}
|
||||||
|
const feedState = SNAPSHOT_FEED_STATES.includes(reply?.feedState)
|
||||||
|
? reply.feedState
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
summary: toHudSummary(reply?.summary, locale),
|
||||||
|
feedState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHudSummary(value, requested) {
|
||||||
|
const locale = hudSummaryLocale(requested);
|
||||||
|
const words = String(value || '')
|
||||||
|
.normalize('NFC')
|
||||||
|
// Marks, and apostrophes and dots inside a word, belong to it: ar vowel
|
||||||
|
// signs, fr d'Orléans, ja サンフランシスコ・ベイエリア.
|
||||||
|
.replace(/[^\p{L}\p{M}\p{N}\s'’·・-]/gu, ' ')
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.filter(Boolean)
|
.map((word) => word.replace(/^['’·・-]+|['’·・-]+$/gu, ''))
|
||||||
.slice(0, 5)
|
.filter(Boolean);
|
||||||
.join(' ');
|
// Characters, not words: the model counts Rue de Rivoli as one word.
|
||||||
|
const max = hudSummaryIsUnspaced(locale)
|
||||||
|
? HUD_SUMMARY_MAX_UNSPACED_CHARS
|
||||||
|
: HUD_SUMMARY_MAX_CHARS;
|
||||||
|
return capAtWord(words.join(' '), max, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cut `text` to `max` characters at a word boundary; a first word longer
|
||||||
|
* than that is cut where the cap falls.
|
||||||
|
*/
|
||||||
|
function capAtWord(text, max, locale) {
|
||||||
|
const graphemes = [
|
||||||
|
...new Intl.Segmenter(locale, { granularity: 'grapheme' }).segment(text),
|
||||||
|
];
|
||||||
|
if (graphemes.length <= max) return text;
|
||||||
|
const cut = graphemes[max].index;
|
||||||
|
let end = 0;
|
||||||
|
for (const { index, segment } of new Intl.Segmenter(locale, {
|
||||||
|
granularity: 'word',
|
||||||
|
}).segment(text)) {
|
||||||
|
if (index + segment.length > cut) break;
|
||||||
|
end = index + segment.length;
|
||||||
|
}
|
||||||
|
return text.slice(0, end).trim() || text.slice(0, cut);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleHudSummary(req, res) {
|
async function handleHudSummary(req, res) {
|
||||||
@@ -77,14 +128,18 @@ async function handleHudSummary(req, res) {
|
|||||||
model:
|
model:
|
||||||
process.env.OPENAI_HUD_SUMMARY_MODEL ||
|
process.env.OPENAI_HUD_SUMMARY_MODEL ||
|
||||||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
||||||
instructions: HUD_SUMMARY_INSTRUCTIONS,
|
instructions: hudSummaryInstructions(context?.locale),
|
||||||
input: JSON.stringify(context),
|
input: hudSummaryInput(context),
|
||||||
|
text: { format: HUD_SUMMARY_RESPONSE_FORMAT },
|
||||||
reasoning: { effort: 'minimal' },
|
reasoning: { effort: 'minimal' },
|
||||||
max_output_tokens: 100,
|
max_output_tokens: 200,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
const summary = toFiveWordHudSummary(extractOpenAiResponseText(data));
|
const { summary, feedState } = parseHudSummaryReply(
|
||||||
|
extractOpenAiResponseText(data),
|
||||||
|
context?.locale,
|
||||||
|
);
|
||||||
res.statusCode = response.ok && summary ? 200 : response.status || 502;
|
res.statusCode = response.ok && summary ? 200 : response.status || 502;
|
||||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
@@ -93,6 +148,7 @@ async function handleHudSummary(req, res) {
|
|||||||
res.end(
|
res.end(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
summary: summary || null,
|
summary: summary || null,
|
||||||
|
feedState,
|
||||||
// Never relay `data.error.message`: that is OpenAI's own wording, and
|
// Never relay `data.error.message`: that is OpenAI's own wording, and
|
||||||
// it carries request ids, organization hints and quota phrasing.
|
// it carries request ids, organization hints and quota phrasing.
|
||||||
error: response.ok ? null : 'OpenAI HUD summary request failed',
|
error: response.ok ? null : 'OpenAI HUD summary request failed',
|
||||||
@@ -110,4 +166,4 @@ async function handleHudSummary(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { handleHudSummary };
|
export { handleHudSummary, parseHudSummaryReply };
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
import {
|
||||||
|
makeRateLimiter,
|
||||||
|
clientKey,
|
||||||
|
clientNetworkKey,
|
||||||
|
isLoopbackPeer,
|
||||||
|
} from './common/rate-limit.js';
|
||||||
|
import { hasProxySignals } from '../../src/localRequestGate.mjs';
|
||||||
import { readRequestBodyCapped } from './common/request.js';
|
import { readRequestBodyCapped } from './common/request.js';
|
||||||
import { admitSameSite } from './common/same-site.js';
|
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||||
import {
|
import {
|
||||||
OVERPASS_MAX_BODY_BYTES,
|
OVERPASS_MAX_BODY_BYTES,
|
||||||
OVERPASS_MAX_CONCURRENT,
|
OVERPASS_MAX_CONCURRENT,
|
||||||
@@ -29,10 +35,21 @@ const _overpassInFlight = new Map();
|
|||||||
let _overpassConcurrent = 0;
|
let _overpassConcurrent = 0;
|
||||||
|
|
||||||
// Bound body-reading and validation work, including malformed requests and
|
// Bound body-reading and validation work, including malformed requests and
|
||||||
// cache hits. Keep the tighter upstream budget below independent of this gate.
|
// cache hits, before a byte is read. Remote peers share one global window so
|
||||||
const _overpassRequestLimiter = makeRateLimiter({
|
// address rotation cannot mint admission without bound (IPv6 peers are keyed
|
||||||
windowMs: 60_000,
|
// by /64 for the same reason). A direct loopback request — the owner's own
|
||||||
max: 300,
|
// browser and the repo QA harnesses, with no forwarding headers — has its own
|
||||||
|
// per-peer window outside that global, so busy LAN peers cannot lock the owner
|
||||||
|
// out of the map. A request relayed by a reverse proxy on this machine also
|
||||||
|
// arrives from loopback, but it is remote traffic: it is admitted from the
|
||||||
|
// remote pool under its own socket-keyed bucket, never from the owner's. The
|
||||||
|
// forwarded client address is not trusted as identity. Only admission is
|
||||||
|
// isolated: the upstream budget below (90/min per key, 300/min globally) is
|
||||||
|
// shared, so an owner request that misses cache can still be refused there.
|
||||||
|
const OVERPASS_ADMISSION_WINDOW = { windowMs: 60_000, max: 300 };
|
||||||
|
const _overpassLoopbackAdmission = makeRateLimiter(OVERPASS_ADMISSION_WINDOW);
|
||||||
|
const _overpassPeerAdmission = makeRateLimiter({
|
||||||
|
...OVERPASS_ADMISSION_WINDOW,
|
||||||
globalMax: 900,
|
globalMax: 900,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,6 +59,33 @@ const _overpassRateLimiter = makeRateLimiter({
|
|||||||
globalMax: 300,
|
globalMax: 300,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a request for admission. Direct loopback — a loopback socket with
|
||||||
|
* no forwarding header — is the owner's own pool; everything else, including a
|
||||||
|
* loopback socket that carries forwarding headers (a local reverse proxy), is
|
||||||
|
* remote. A proxied request keeps its socket identity under a `proxied:`
|
||||||
|
* prefix, so it never shares a bucket with the owner's direct requests.
|
||||||
|
* @param {import('http').IncomingMessage} req
|
||||||
|
* @returns {{direct: boolean, key: string}}
|
||||||
|
*/
|
||||||
|
function overpassAdmissionClass(req) {
|
||||||
|
const peer = clientNetworkKey(req);
|
||||||
|
if (!isLoopbackPeer(clientKey(req))) return { direct: false, key: peer };
|
||||||
|
if (hasProxySignals(req.headers || {}))
|
||||||
|
return { direct: false, key: `proxied:${peer}` };
|
||||||
|
return { direct: true, key: peer };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spend one admission slot for this request; false when its bucket is over its
|
||||||
|
* window (or, for remote traffic, when the shared global window is full).
|
||||||
|
* @param {{direct: boolean, key: string}} admission - `overpassAdmissionClass(req)`.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function admitOverpassRequest({ direct, key }) {
|
||||||
|
return (direct ? _overpassLoopbackAdmission : _overpassPeerAdmission)(key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write a completed Overpass payload to the HTTP response.
|
* Write a completed Overpass payload to the HTTP response.
|
||||||
*
|
*
|
||||||
@@ -103,8 +147,13 @@ function overpassProxy({ routing = {} } = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (admitSameSite(req, res)) return;
|
// Keyless gate: refuses cross-site browser requests but, unlike the
|
||||||
if (!_overpassRequestLimiter(clientKey(req))) {
|
// cost-bearing routes, admits the forwarding headers a reverse proxy
|
||||||
|
// adds — the layers that depend on Overpass must keep working for an
|
||||||
|
// instance deployed the way the README documents.
|
||||||
|
if (admitSameSiteKeyless(req, res)) return;
|
||||||
|
const admission = overpassAdmissionClass(req);
|
||||||
|
if (!admitOverpassRequest(admission)) {
|
||||||
res.writeHead(429, {
|
res.writeHead(429, {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
@@ -163,7 +212,7 @@ function overpassProxy({ routing = {} } = {}) {
|
|||||||
// requests; boundary-class queries keep their month-long TTL.
|
// requests; boundary-class queries keep their month-long TTL.
|
||||||
readDisk: () =>
|
readDisk: () =>
|
||||||
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
||||||
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
|
allowUpstream: () => _overpassRateLimiter(admission.key),
|
||||||
loadUpstream: async () => {
|
loadUpstream: async () => {
|
||||||
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT)
|
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -122,12 +122,14 @@ function sanitizeOverpassBody(rawBody) {
|
|||||||
const boundedSets = new Set();
|
const boundedSets = new Set();
|
||||||
for (let stmt of withoutTags.split(';')) {
|
for (let stmt of withoutTags.split(';')) {
|
||||||
stmt = stmt.trim();
|
stmt = stmt.trim();
|
||||||
if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue;
|
// The settings block (`[out:json][timeout:25]`) was blanked together with
|
||||||
|
// the tag filters, so it arrives here as an empty statement.
|
||||||
|
if (!stmt || /^out\b/.test(stmt)) continue;
|
||||||
|
|
||||||
// Strip output-set assignments (NOT input bounds). Bracket tag filters were
|
// Strip output-set assignments (NOT input bounds). With the tag filters
|
||||||
// removed above so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot be
|
// already gone, a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot
|
||||||
// misread as a spatial bound. Bounds live in (...) / function calls / set
|
// be misread as a spatial bound; bounds live in (...) / function calls /
|
||||||
// refs, never inside [...], so the probe loses nothing real.
|
// set refs, never inside [...], so nothing real was lost.
|
||||||
const outSets = [];
|
const outSets = [];
|
||||||
const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
|
const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
|
||||||
outSets.push(name);
|
outSets.push(name);
|
||||||
@@ -166,8 +168,10 @@ function sanitizeOverpassBody(rawBody) {
|
|||||||
if (bounded) for (const name of outSets) boundedSets.add(name);
|
if (bounded) for (const name of outSets) boundedSets.add(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tolerate whitespace around the directive so `[ timeout : N ]` is clamped
|
||||||
|
// rather than passed through as written.
|
||||||
const clamped = data.replace(
|
const clamped = data.replace(
|
||||||
/\[timeout:\s*(\d+)\s*\]/gi,
|
/\[\s*timeout\s*:\s*(\d+)\s*\]/gi,
|
||||||
(_, n) =>
|
(_, n) =>
|
||||||
`[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`,
|
`[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,10 +3,24 @@ import assert from 'node:assert/strict';
|
|||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { overpassProxy } from '../overpass.js';
|
import { overpassProxy } from '../overpass.js';
|
||||||
|
import { _overpassCache } from './cache.js';
|
||||||
import { sanitizeOverpassBody } from './query.js';
|
import { sanitizeOverpassBody } from './query.js';
|
||||||
|
|
||||||
const form = (query) => `data=${encodeURIComponent(query)}`;
|
const form = (query) => `data=${encodeURIComponent(query)}`;
|
||||||
|
|
||||||
|
// The limiters are module-level, so every test that spends admission mocks a
|
||||||
|
// clock a full day past the previous one in execution order: no test can see
|
||||||
|
// another's window, whatever order (or randomization) they run in. A new
|
||||||
|
// test that reaches admission must call it too: earlier tests leave
|
||||||
|
// timestamps days ahead of the real clock, and those still count.
|
||||||
|
let clockBase = Date.now() + 86_400_000;
|
||||||
|
function freshClock(t) {
|
||||||
|
clockBase += 86_400_000;
|
||||||
|
const clock = { now: clockBase };
|
||||||
|
t.mock.method(Date, 'now', () => clock.now);
|
||||||
|
return clock;
|
||||||
|
}
|
||||||
|
|
||||||
test('bracket validation rejects malformed filters without trusting their contents as bounds', () => {
|
test('bracket validation rejects malformed filters without trusting their contents as bounds', () => {
|
||||||
for (const query of [
|
for (const query of [
|
||||||
'node[',
|
'node[',
|
||||||
@@ -37,6 +51,15 @@ test('bounded queries preserve quoted brackets, comments, multiple tags and time
|
|||||||
query.replace('[timeout:999]', '[timeout:30]'),
|
query.replace('[timeout:999]', '[timeout:30]'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Whitespace inside the directive is clamped too, not passed through.
|
||||||
|
const spaced = sanitizeOverpassBody(
|
||||||
|
form('[out:json][ timeout : 999 ];node(around:20,1,1);out;'),
|
||||||
|
);
|
||||||
|
assert.equal(spaced.ok, true);
|
||||||
|
assert.equal(
|
||||||
|
new URLSearchParams(spaced.body).get('data'),
|
||||||
|
'[out:json][timeout:30];node(around:20,1,1);out;',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a burst of maximum-size unterminated filters completes within a bounded process deadline', () => {
|
test('a burst of maximum-size unterminated filters completes within a bounded process deadline', () => {
|
||||||
@@ -126,7 +149,13 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
|
|||||||
{ 'sec-fetch-site': 'cross-site' },
|
{ 'sec-fetch-site': 'cross-site' },
|
||||||
{ 'sec-fetch-site': 'same-site' },
|
{ 'sec-fetch-site': 'same-site' },
|
||||||
{ origin: 'null' },
|
{ origin: 'null' },
|
||||||
{ 'x-forwarded-for': '127.0.0.1' },
|
// Forwarding headers do not launder a cross-site request.
|
||||||
|
{ 'x-forwarded-for': '10.0.0.9', 'sec-fetch-site': 'cross-site' },
|
||||||
|
{
|
||||||
|
'x-forwarded-proto': 'https',
|
||||||
|
origin: 'http://localhost:4173',
|
||||||
|
'sec-fetch-site': 'same-origin',
|
||||||
|
},
|
||||||
]) {
|
]) {
|
||||||
const response = await call(route, { headers, forbidRead: true });
|
const response = await call(route, { headers, forbidRead: true });
|
||||||
assert.equal(response.statusCode, 403);
|
assert.equal(response.statusCode, 403);
|
||||||
@@ -136,12 +165,24 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('same-origin, LAN and non-browser requests still reach validation; GET status stays available', async () => {
|
test('same-origin, LAN, proxied and non-browser requests still reach validation; GET status stays available', async (t) => {
|
||||||
|
freshClock(t);
|
||||||
const route = handler();
|
const route = handler();
|
||||||
for (const headers of [
|
for (const headers of [
|
||||||
{},
|
{},
|
||||||
{ origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' },
|
{ origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' },
|
||||||
{ host: '192.168.1.10:4173', origin: 'http://192.168.1.10:4173' },
|
{ host: '192.168.1.10:4173', origin: 'http://192.168.1.10:4173' },
|
||||||
|
// The README's reverse-proxy deployment: Overpass is keyless, so the
|
||||||
|
// forwarding headers the proxy adds are admitted (as for terrain heights).
|
||||||
|
{
|
||||||
|
host: 'globe.example.lan',
|
||||||
|
origin: 'https://globe.example.lan',
|
||||||
|
'sec-fetch-site': 'same-origin',
|
||||||
|
'x-forwarded-for': '203.0.113.7',
|
||||||
|
'x-forwarded-proto': 'https',
|
||||||
|
'x-forwarded-host': 'globe.example.lan',
|
||||||
|
},
|
||||||
|
{ 'x-forwarded-for': '127.0.0.1' },
|
||||||
]) {
|
]) {
|
||||||
const response = await call(route, { headers });
|
const response = await call(route, { headers });
|
||||||
assert.equal(response.statusCode, 400);
|
assert.equal(response.statusCode, 400);
|
||||||
@@ -158,8 +199,7 @@ test('same-origin, LAN and non-browser requests still reach validation; GET stat
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('invalid requests spend admission quota before parsing, including on an unconfigured server', async (t) => {
|
test('invalid requests spend admission quota before parsing, including on an unconfigured server', async (t) => {
|
||||||
let now = Date.now() + 120000;
|
const clock = freshClock(t);
|
||||||
t.mock.method(Date, 'now', () => now);
|
|
||||||
t.mock.method(globalThis, 'fetch', () =>
|
t.mock.method(globalThis, 'fetch', () =>
|
||||||
assert.fail('unexpected upstream fetch'),
|
assert.fail('unexpected upstream fetch'),
|
||||||
);
|
);
|
||||||
@@ -173,6 +213,216 @@ test('invalid requests spend admission quota before parsing, including on an unc
|
|||||||
assert.equal(denied.reads, 0);
|
assert.equal(denied.reads, 0);
|
||||||
assert.equal(denied.headers['retry-after'], '60');
|
assert.equal(denied.headers['retry-after'], '60');
|
||||||
assert.equal((await call(route, { peer: 'other-peer' })).statusCode, 400);
|
assert.equal((await call(route, { peer: 'other-peer' })).statusCode, 400);
|
||||||
now += 60001;
|
clock.now += 60_001;
|
||||||
assert.equal((await call(route, { peer })).statusCode, 400);
|
assert.equal((await call(route, { peer })).statusCode, 400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the 900/min global window spans non-loopback peers only; loopback keeps its own window', async (t) => {
|
||||||
|
const clock = freshClock(t);
|
||||||
|
t.mock.method(globalThis, 'fetch', () =>
|
||||||
|
assert.fail('unexpected upstream fetch'),
|
||||||
|
);
|
||||||
|
const route = handler();
|
||||||
|
for (const peer of ['10.0.0.1', '10.0.0.2', '10.0.0.3']) {
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
assert.equal((await call(route, { peer })).statusCode, 400, peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const starved = await call(route, { peer: '10.0.0.4', forbidRead: true });
|
||||||
|
assert.equal(starved.statusCode, 429, 'global window is full for LAN peers');
|
||||||
|
assert.equal(starved.reads, 0);
|
||||||
|
for (const peer of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) {
|
||||||
|
const owner = await call(route, { peer });
|
||||||
|
assert.equal(owner.statusCode, 400, `${peer} is admitted past the global`);
|
||||||
|
assert.equal(owner.reads, 1);
|
||||||
|
}
|
||||||
|
clock.now += 60_001;
|
||||||
|
assert.equal((await call(route, { peer: '10.0.0.4' })).statusCode, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('IPv6 peers share a /64 admission bucket and IPv4-mapped peers fold to IPv4', async (t) => {
|
||||||
|
const clock = freshClock(t);
|
||||||
|
t.mock.method(globalThis, 'fetch', () =>
|
||||||
|
assert.fail('unexpected upstream fetch'),
|
||||||
|
);
|
||||||
|
const route = handler();
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
assert.equal(
|
||||||
|
(await call(route, { peer: `2001:db8:1:2::${(i % 200) + 1}` }))
|
||||||
|
.statusCode,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rotated = await call(route, {
|
||||||
|
peer: '2001:db8:1:2:ffff:ffff:ffff:9',
|
||||||
|
forbidRead: true,
|
||||||
|
});
|
||||||
|
assert.equal(rotated.statusCode, 429, 'a rotating host stays in one bucket');
|
||||||
|
assert.equal(
|
||||||
|
(await call(route, { peer: '2001:db8:1:3::1' })).statusCode,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
assert.equal(
|
||||||
|
(await call(route, { peer: '::ffff:10.1.1.1' })).statusCode,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const unmapped = await call(route, { peer: '10.1.1.1', forbidRead: true });
|
||||||
|
assert.equal(unmapped.statusCode, 429, 'mapped and bare IPv4 share a bucket');
|
||||||
|
clock.now += 60_001;
|
||||||
|
assert.equal((await call(route, { peer: '10.1.1.1' })).statusCode, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cache hits spend admission budget but not the 90/min upstream budget', async (t) => {
|
||||||
|
const clock = freshClock(t);
|
||||||
|
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||||
|
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
|
||||||
|
t.after(() => {
|
||||||
|
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
|
||||||
|
else process.env.OVERPASS_UPSTREAMS = prior;
|
||||||
|
_overpassCache.delete(hitKey);
|
||||||
|
});
|
||||||
|
let fetches = 0;
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => {
|
||||||
|
fetches++;
|
||||||
|
// Not data: nothing is cached or written to disk by this test.
|
||||||
|
return new Response('upstream unavailable', { status: 503 });
|
||||||
|
});
|
||||||
|
const hit = 'node(around:20,1,1);out;';
|
||||||
|
const hitKey = `data=${encodeURIComponent(hit)}`;
|
||||||
|
_overpassCache.set(hitKey, {
|
||||||
|
status: 200,
|
||||||
|
body: '{"elements":[]}',
|
||||||
|
contentType: 'application/json',
|
||||||
|
endpoint: 'https://overpass.example/api/interpreter',
|
||||||
|
cachedAt: clock.now,
|
||||||
|
});
|
||||||
|
const route = handler();
|
||||||
|
const peer = '10.7.7.7';
|
||||||
|
// More cache hits than the upstream limiter would allow per peer.
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const response = await call(route, { peer, body: form(hit) });
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.equal(response.headers['x-overpass-cache'], 'HIT');
|
||||||
|
}
|
||||||
|
assert.equal(fetches, 0);
|
||||||
|
const miss = await call(route, {
|
||||||
|
peer,
|
||||||
|
body: form('node(around:20,2,2);out;'),
|
||||||
|
});
|
||||||
|
assert.equal(fetches, 1, 'the first miss still reaches upstream');
|
||||||
|
assert.notEqual(miss.statusCode, 429);
|
||||||
|
for (let i = 0; i < 199; i++) {
|
||||||
|
assert.equal(
|
||||||
|
(await call(route, { peer, body: form(hit) })).statusCode,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const exhausted = await call(route, {
|
||||||
|
peer,
|
||||||
|
body: form(hit),
|
||||||
|
forbidRead: true,
|
||||||
|
});
|
||||||
|
assert.equal(exhausted.statusCode, 429, 'hits spent the admission window');
|
||||||
|
assert.equal(exhausted.reads, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a local reverse proxy cannot spend the direct-loopback reserve; owner cache hits survive it', async (t) => {
|
||||||
|
const clock = freshClock(t);
|
||||||
|
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||||
|
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
|
||||||
|
const hit = 'node(around:20,3,3);out;';
|
||||||
|
const hitKey = `data=${encodeURIComponent(hit)}`;
|
||||||
|
t.after(() => {
|
||||||
|
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
|
||||||
|
else process.env.OVERPASS_UPSTREAMS = prior;
|
||||||
|
_overpassCache.delete(hitKey);
|
||||||
|
});
|
||||||
|
t.mock.method(globalThis, 'fetch', () =>
|
||||||
|
assert.fail('unexpected upstream fetch'),
|
||||||
|
);
|
||||||
|
_overpassCache.set(hitKey, {
|
||||||
|
status: 200,
|
||||||
|
body: '{"elements":[]}',
|
||||||
|
contentType: 'application/json',
|
||||||
|
endpoint: 'https://overpass.example/api/interpreter',
|
||||||
|
cachedAt: clock.now,
|
||||||
|
});
|
||||||
|
const route = handler();
|
||||||
|
// The proxy connects over loopback; a rotating forwarded client address
|
||||||
|
// must not mint fresh buckets, so all 300 land in one socket-keyed bucket.
|
||||||
|
for (const proxyPeer of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) {
|
||||||
|
const proxied = (i) => ({
|
||||||
|
peer: proxyPeer,
|
||||||
|
headers: { 'x-forwarded-for': `203.0.113.${i % 250}` },
|
||||||
|
body: form(hit),
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
assert.equal((await call(route, proxied(i))).statusCode, 200, proxyPeer);
|
||||||
|
}
|
||||||
|
const refused = await call(route, { ...proxied(300), forbidRead: true });
|
||||||
|
assert.equal(refused.statusCode, 429, `${proxyPeer} proxy bucket is full`);
|
||||||
|
assert.equal(refused.reads, 0);
|
||||||
|
for (const peer of ['127.0.0.1', '::1']) {
|
||||||
|
const owner = await call(route, { peer, body: form(hit) });
|
||||||
|
assert.equal(owner.statusCode, 200, `direct ${peer} is still admitted`);
|
||||||
|
assert.equal(owner.headers['x-overpass-cache'], 'HIT');
|
||||||
|
}
|
||||||
|
clock.now += 60_001;
|
||||||
|
assert.equal((await call(route, proxied(0))).statusCode, 200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proxied loopback traffic draws on the shared remote global window', async (t) => {
|
||||||
|
const clock = freshClock(t);
|
||||||
|
t.mock.method(globalThis, 'fetch', () =>
|
||||||
|
assert.fail('unexpected upstream fetch'),
|
||||||
|
);
|
||||||
|
const route = handler();
|
||||||
|
for (const peer of ['10.0.0.1', '10.0.0.2']) {
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
assert.equal((await call(route, { peer })).statusCode, 400, peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
assert.equal(
|
||||||
|
(await call(route, { headers: { via: '1.1 proxy' } })).statusCode,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const starved = await call(route, { peer: '10.0.0.3', forbidRead: true });
|
||||||
|
assert.equal(starved.statusCode, 429, 'proxied requests counted globally');
|
||||||
|
assert.equal((await call(route, {})).statusCode, 400, 'owner unaffected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a local proxy has its own upstream bucket, so its misses cannot spend the owner's", async (t) => {
|
||||||
|
freshClock(t);
|
||||||
|
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||||
|
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
|
||||||
|
t.after(() => {
|
||||||
|
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
|
||||||
|
else process.env.OVERPASS_UPSTREAMS = prior;
|
||||||
|
});
|
||||||
|
// Not data: nothing is cached, so every request below is a miss.
|
||||||
|
t.mock.method(
|
||||||
|
globalThis,
|
||||||
|
'fetch',
|
||||||
|
async () => new Response('upstream unavailable', { status: 503 }),
|
||||||
|
);
|
||||||
|
const route = handler();
|
||||||
|
const miss = form('node(around:20,4,4);out;');
|
||||||
|
// The upstream limiter's own refusal is the only miss without this header.
|
||||||
|
const passedLimiter = (response) =>
|
||||||
|
response.headers['x-overpass-cache'] !== undefined;
|
||||||
|
const proxied = { peer: '127.0.0.1', headers: { via: '1.1 proxy' } };
|
||||||
|
for (let i = 0; i < 90; i++) {
|
||||||
|
const response = await call(route, { ...proxied, body: miss });
|
||||||
|
assert.equal(passedLimiter(response), true, `proxied miss ${i}`);
|
||||||
|
}
|
||||||
|
const spent = await call(route, { ...proxied, body: miss });
|
||||||
|
assert.equal(spent.statusCode, 429);
|
||||||
|
assert.equal(passedLimiter(spent), false, 'the proxy bucket is spent');
|
||||||
|
const owner = await call(route, { peer: '127.0.0.1', body: miss });
|
||||||
|
assert.equal(passedLimiter(owner), true, 'the owner keeps its own bucket');
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const DIR = fileURLToPath(
|
||||||
|
new URL('../../src/render/conformance/goldens/visual/', import.meta.url),
|
||||||
|
);
|
||||||
|
const NAME = /^[a-z0-9-]+\.(png|json)$/;
|
||||||
|
const MAX_BYTES = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev server only: lets `/dev/conformance/` save the visual references it
|
||||||
|
* records into `src/render/conformance/goldens/visual/`. Takes a PUT of one
|
||||||
|
* PNG or the manifest, named in the path; nothing else is written.
|
||||||
|
*/
|
||||||
|
export function conformanceReferencesPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'conformance-references',
|
||||||
|
apply: 'serve',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use('/__conformance/references/', (req, res) => {
|
||||||
|
const name = decodeURIComponent((req.url ?? '').replace(/^\//, ''));
|
||||||
|
// Only the page on this machine: a loopback peer, sent from the
|
||||||
|
// dev server's own origin.
|
||||||
|
const peer = req.socket.remoteAddress ?? '';
|
||||||
|
const loopback = /^(127\.|::1$|::ffff:127\.)/.test(peer);
|
||||||
|
let sameOrigin = false;
|
||||||
|
try {
|
||||||
|
sameOrigin = new URL(req.headers.origin).host === req.headers.host;
|
||||||
|
} catch {
|
||||||
|
// No Origin, or not a URL.
|
||||||
|
}
|
||||||
|
if (!loopback || !sameOrigin) {
|
||||||
|
res.writeHead(403).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method !== 'PUT' || !NAME.test(name)) {
|
||||||
|
res.writeHead(400).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > MAX_BYTES) req.destroy();
|
||||||
|
else chunks.push(chunk);
|
||||||
|
});
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
await mkdir(DIR, { recursive: true });
|
||||||
|
await writeFile(DIR + name, Buffer.concat(chunks));
|
||||||
|
res.writeHead(204).end();
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500).end(String(error?.message ?? error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { createBrowserViteConfig } from '../../build/vite.js';
|
|||||||
import { localProviderPlugins } from '../providers/local.js';
|
import { localProviderPlugins } from '../providers/local.js';
|
||||||
import { localMcpPlugin } from '../mcp/plugin.js';
|
import { localMcpPlugin } from '../mcp/plugin.js';
|
||||||
import { apiNotFoundPlugin } from './api-not-found.js';
|
import { apiNotFoundPlugin } from './api-not-found.js';
|
||||||
|
import { conformanceReferencesPlugin } from './conformance-references.js';
|
||||||
import { googleTokenUnavailablePlugin } from './google-token-unavailable.js';
|
import { googleTokenUnavailablePlugin } from './google-token-unavailable.js';
|
||||||
import { standaloneVoiceTools } from './voiceTools.js';
|
import { standaloneVoiceTools } from './voiceTools.js';
|
||||||
import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js';
|
import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js';
|
||||||
@@ -30,6 +31,7 @@ export function standaloneConfig(
|
|||||||
}),
|
}),
|
||||||
localMcpPlugin(),
|
localMcpPlugin(),
|
||||||
googleTokenUnavailablePlugin(),
|
googleTokenUnavailablePlugin(),
|
||||||
|
conformanceReferencesPlugin(),
|
||||||
apiNotFoundPlugin(),
|
apiNotFoundPlugin(),
|
||||||
unwatchedRuntimeDirsPlugin(),
|
unwatchedRuntimeDirsPlugin(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as Cesium from 'cesium';
|
import { toLonLatH } from '../geo/index.js';
|
||||||
import {
|
import {
|
||||||
feedProvenanceEnvelope,
|
feedProvenanceEnvelope,
|
||||||
layerSnapshot,
|
layerSnapshot,
|
||||||
@@ -109,9 +109,7 @@ export function getWorldPacket(options = {}) {
|
|||||||
if (!dataManager || typeof dataManager.getAll !== 'function')
|
if (!dataManager || typeof dataManager.getAll !== 'function')
|
||||||
throw new TypeError('World Packet requires a data manager');
|
throw new TypeError('World Packet requires a data manager');
|
||||||
|
|
||||||
const cartographic = Cesium.Cartographic.fromCartesian(
|
const eye = toLonLatH(viewer.camera.positionWC);
|
||||||
viewer.camera.positionWC,
|
|
||||||
);
|
|
||||||
// Read the roster once. Voice must not rewalk disabled layers: preserving the
|
// Read the roster once. Voice must not rewalk disabled layers: preserving the
|
||||||
// current enabled-only view keeps the model's context budget bounded.
|
// current enabled-only view keeps the model's context budget bounded.
|
||||||
const rows = dataManager.getAll();
|
const rows = dataManager.getAll();
|
||||||
@@ -126,9 +124,9 @@ export function getWorldPacket(options = {}) {
|
|||||||
ok: true,
|
ok: true,
|
||||||
action: 'get_current_view_state',
|
action: 'get_current_view_state',
|
||||||
camera: {
|
camera: {
|
||||||
latitude: Cesium.Math.toDegrees(cartographic.latitude),
|
latitude: eye.lat,
|
||||||
longitude: Cesium.Math.toDegrees(cartographic.longitude),
|
longitude: eye.lon,
|
||||||
heightM: cartographic.height,
|
heightM: eye.height,
|
||||||
},
|
},
|
||||||
style: styleManager?.activeStyle || 'normal',
|
style: styleManager?.activeStyle || 'normal',
|
||||||
context: contextState
|
context: contextState
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { isUnavailableCapability } from '../sources/capability.js';
|
import { isUnavailableCapability } from '../sources/capability.js';
|
||||||
import { defaultGeospatial } from '../search/defaults.js';
|
import { defaultGeospatial } from '../search/defaults.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { cameraOf } from '../render/cesium/camera.js';
|
||||||
import { greatCircleMeters } from '../geoDistance.js';
|
import { greatCircleMeters } from '../geoDistance.js';
|
||||||
import {
|
import {
|
||||||
holdContinuousRender,
|
holdContinuousRender,
|
||||||
@@ -1700,14 +1701,18 @@ export function createAnnotationEngine({
|
|||||||
Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0),
|
Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0),
|
||||||
);
|
);
|
||||||
const sphere = Cesium.BoundingSphere.fromPoints(cart);
|
const sphere = Cesium.BoundingSphere.fromPoints(cart);
|
||||||
viewer.camera.flyToBoundingSphere(sphere, {
|
const camera = cameraOf(viewer);
|
||||||
offset: new Cesium.HeadingPitchRange(
|
camera.flyToBounds(
|
||||||
viewer.camera.heading,
|
{ center: sphere.center, radius: sphere.radius },
|
||||||
Cesium.Math.toRadians(-35),
|
{
|
||||||
Math.max(900, sphere.radius * 2.6),
|
offset: {
|
||||||
),
|
heading: camera.pose().heading,
|
||||||
duration: 1.6,
|
pitch: Cesium.Math.toRadians(-35),
|
||||||
});
|
range: Math.max(900, sphere.radius * 2.6),
|
||||||
|
},
|
||||||
|
durationS: 1.6,
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
/* camera assist is best-effort */
|
/* camera assist is best-effort */
|
||||||
}
|
}
|
||||||
@@ -1757,22 +1762,23 @@ export function createAnnotationEngine({
|
|||||||
: anno.ring,
|
: anno.ring,
|
||||||
)
|
)
|
||||||
: viewportRange(anno.viewport) || 600;
|
: viewportRange(anno.viewport) || 600;
|
||||||
viewer.camera.flyToBoundingSphere(
|
const camera = cameraOf(viewer);
|
||||||
new Cesium.BoundingSphere(
|
camera.flyToBounds(
|
||||||
Cesium.Cartesian3.fromDegrees(
|
|
||||||
target.lon,
|
|
||||||
target.lat,
|
|
||||||
anno.anchor.height || 0,
|
|
||||||
),
|
|
||||||
range,
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
offset: new Cesium.HeadingPitchRange(
|
center: {
|
||||||
viewer.camera.heading,
|
lon: target.lon,
|
||||||
Cesium.Math.toRadians(-35),
|
lat: target.lat,
|
||||||
range * 2.4,
|
height: anno.anchor.height || 0,
|
||||||
),
|
},
|
||||||
duration: 1.8,
|
radius: range,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: {
|
||||||
|
heading: camera.pose().heading,
|
||||||
|
pitch: Cesium.Math.toRadians(-35),
|
||||||
|
range: range * 2.4,
|
||||||
|
},
|
||||||
|
durationS: 1.8,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1978,15 +1984,17 @@ export function createAnnotationEngine({
|
|||||||
}) {
|
}) {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
try {
|
try {
|
||||||
viewer.camera.flyTo({
|
cameraOf(viewer).flyTo(
|
||||||
destination: Cesium.Cartesian3.fromDegrees(lon, lat, height),
|
{ lon, lat, height },
|
||||||
orientation: {
|
{
|
||||||
heading: Cesium.Math.toRadians(heading),
|
orientation: {
|
||||||
pitch: Cesium.Math.toRadians(pitch),
|
heading: Cesium.Math.toRadians(heading),
|
||||||
roll: 0,
|
pitch: Cesium.Math.toRadians(pitch),
|
||||||
|
roll: 0,
|
||||||
|
},
|
||||||
|
durationS: duration,
|
||||||
},
|
},
|
||||||
duration,
|
);
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
/* camera flight best-effort */
|
/* camera flight best-effort */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||||
import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
|
import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
|
||||||
// Voice-annotation resilience contract tests — pure logic, no network, no browser.
|
// Voice-annotation resilience contract tests — pure logic, no network, no browser.
|
||||||
//
|
//
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
installRenderGovernor,
|
installRenderGovernor,
|
||||||
_resetRenderGovernorForTest,
|
_resetRenderGovernorForTest,
|
||||||
} from '../renderGovernor.js';
|
} from '../renderGovernor.js';
|
||||||
|
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
|
||||||
|
|
||||||
// A resolver that replays a scripted sequence of outcomes (undefined = transient,
|
// A resolver that replays a scripted sequence of outcomes (undefined = transient,
|
||||||
// null = definitive miss, object = footprint) and counts its invocations.
|
// null = definitive miss, object = footprint) and counts its invocations.
|
||||||
@@ -1261,7 +1263,7 @@ test('hand-drawn marks: one mark can be hidden, renamed, recoloured, framed and
|
|||||||
const { renderer, calls } = fakeRenderer();
|
const { renderer, calls } = fakeRenderer();
|
||||||
const flights = [];
|
const flights = [];
|
||||||
const engine = createAnnotationEngine({
|
const engine = createAnnotationEngine({
|
||||||
viewer: { camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } },
|
viewer: bindFakeCesiumCamera({ camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } }),
|
||||||
renderer,
|
renderer,
|
||||||
});
|
});
|
||||||
let changes = 0;
|
let changes = 0;
|
||||||
@@ -1330,12 +1332,12 @@ test('hand-drawn marks: remove, hide, clear, expiry and framing have their effec
|
|||||||
const { renderer, calls } = fakeRenderer();
|
const { renderer, calls } = fakeRenderer();
|
||||||
const flights = [];
|
const flights = [];
|
||||||
const engine = createAnnotationEngine({
|
const engine = createAnnotationEngine({
|
||||||
viewer: {
|
viewer: bindFakeCesiumCamera({
|
||||||
camera: {
|
camera: {
|
||||||
heading: 0,
|
heading: 0,
|
||||||
flyToBoundingSphere: (sphere) => flights.push(sphere),
|
flyToBoundingSphere: (sphere) => flights.push(sphere),
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
renderer,
|
renderer,
|
||||||
});
|
});
|
||||||
let changes = 0;
|
let changes = 0;
|
||||||
@@ -1547,7 +1549,7 @@ test('hand-drawn marks: with every mark hidden the scene idles, and showing one
|
|||||||
_resetRenderGovernorForTest();
|
_resetRenderGovernorForTest();
|
||||||
t.after(() => _resetRenderGovernorForTest());
|
t.after(() => _resetRenderGovernorForTest());
|
||||||
let renders = 0;
|
let renders = 0;
|
||||||
installRenderGovernor({ scene: { requestRenderMode: true, requestRender: () => (renders += 1) } });
|
installRenderGovernor(framePortFor({ requestRenderMode: true, requestRender: () => (renders += 1) }));
|
||||||
const { renderer } = fakeRenderer();
|
const { renderer } = fakeRenderer();
|
||||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||||
await engine.annotate([
|
await engine.annotate([
|
||||||
|
|||||||
+32
-62
@@ -18,9 +18,9 @@
|
|||||||
* exceptions inside each.
|
* exceptions inside each.
|
||||||
* - Cesium's Viewer binds its OWN left click (select the entity under the
|
* - Cesium's Viewer binds its OWN left click (select the entity under the
|
||||||
* pointer) and double click (track it) on the viewer's handler, outside every
|
* pointer) and double click (track it) on the viewer's handler, outside every
|
||||||
* layer and therefore outside the shared claim. Both are borrowed for the
|
* layer and therefore outside the shared claim. Both are suppressed for the
|
||||||
* session and given back on the way out, and `destroy()` gives back every
|
* session and given back on the way out, and `destroy()` gives back every
|
||||||
* listener, the Cesium handler, the preview data source and the window
|
* listener, the pointer subscriptions, the preview data source and the window
|
||||||
* handle, so the tool can be turned off or the shell disposed without leaving
|
* handle, so the tool can be turned off or the shell disposed without leaving
|
||||||
* anything behind.
|
* anything behind.
|
||||||
*
|
*
|
||||||
@@ -37,6 +37,8 @@ import {
|
|||||||
pointerOwner,
|
pointerOwner,
|
||||||
releasePointer,
|
releasePointer,
|
||||||
} from '../data/inputOwnership.js';
|
} from '../data/inputOwnership.js';
|
||||||
|
import { inputFor } from '../input/dispatcher.js';
|
||||||
|
import { sensorPickPosition } from '../input/sensorPick.js';
|
||||||
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
|
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
|
||||||
import {
|
import {
|
||||||
DRAW_SHAPES,
|
DRAW_SHAPES,
|
||||||
@@ -88,10 +90,8 @@ export function initDrawTool({
|
|||||||
let shape = 'area';
|
let shape = 'area';
|
||||||
let color = 'primary';
|
let color = 'primary';
|
||||||
let hint = '';
|
let hint = '';
|
||||||
let handler = null;
|
let unbindScene = null;
|
||||||
let lease = null;
|
let lease = null;
|
||||||
let savedDoubleClick = null;
|
|
||||||
let savedSingleClick = null;
|
|
||||||
let cursor = null; // last mouse position on the canvas, for the rubber band
|
let cursor = null; // last mouse position on the canvas, for the rubber band
|
||||||
// Bumped by anything that supersedes an in-flight finish: cancelling, clearing
|
// Bumped by anything that supersedes an in-flight finish: cancelling, clearing
|
||||||
// the board, leaving draw mode, teardown. An `annotate()` that resolves after
|
// the board, leaving draw mode, teardown. An `annotate()` that resolves after
|
||||||
@@ -228,7 +228,9 @@ export function initDrawTool({
|
|||||||
};
|
};
|
||||||
const onMove = (event) => {
|
const onMove = (event) => {
|
||||||
if (!session || session.shape === 'pin' || destroyed) return;
|
if (!session || session.shape === 'pin' || destroyed) return;
|
||||||
const p = worldAt(event.endPosition);
|
// The click's own position is sensor-corrected; the rubber band must
|
||||||
|
// follow the same point the click would place.
|
||||||
|
const p = worldAt(sensorPickPosition(viewer.scene, event.endPosition));
|
||||||
cursor = p
|
cursor = p
|
||||||
? Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0)
|
? Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0)
|
||||||
: null;
|
: null;
|
||||||
@@ -384,50 +386,29 @@ export function initDrawTool({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bindSceneHandler() {
|
function bindSceneHandler() {
|
||||||
if (handler) return;
|
if (unbindScene) return;
|
||||||
handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
const input = inputFor(viewer);
|
||||||
handler.setInputAction(onClick, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
const held = { lease: () => lease };
|
||||||
handler.setInputAction(onMove, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
const offs = [
|
||||||
handler.setInputAction(() => {
|
input.onPointer('click', onClick, held),
|
||||||
void finish();
|
input.onPointer('move', onMove, held),
|
||||||
}, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
|
input.onPointer('dblclick', () => void finish(), held),
|
||||||
// Cesium's Viewer binds BOTH stock click actions on its own handler: the
|
// Cesium's Viewer binds BOTH stock click actions on its own handler: the
|
||||||
// single click picks an entity into `viewer.selectedEntity`, the double
|
// single click picks an entity into `viewer.selectedEntity`, the double
|
||||||
// click tracks it. Neither goes through a layer, so neither can be fixed by
|
// click tracks it. Neither goes through a layer, so neither yields to the
|
||||||
// the shared pointer claim — they have to be borrowed outright for the
|
// shared pointer claim — they are suppressed for the session and given
|
||||||
// session and given back on the way out. Borrowing the single click is what
|
// back on the way out. Suppressing the single click is what stops a
|
||||||
// stops a vertex placed on a contact from also selecting it.
|
// vertex placed on a contact from also selecting it.
|
||||||
const stock = viewer.screenSpaceEventHandler;
|
input.suppressStockClicks(),
|
||||||
savedSingleClick =
|
];
|
||||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null;
|
unbindScene = () => offs.forEach((off) => off());
|
||||||
savedDoubleClick =
|
|
||||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
|
||||||
null;
|
|
||||||
stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
|
||||||
stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
|
|
||||||
listen(document, 'keydown', onKey, true);
|
listen(document, 'keydown', onKey, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseSceneHandler() {
|
function releaseSceneHandler() {
|
||||||
if (handler) {
|
unbindScene?.();
|
||||||
handler.destroy();
|
unbindScene = null;
|
||||||
handler = null;
|
// Drop the keydown listener bound alongside the pointer subscriptions.
|
||||||
}
|
|
||||||
if (savedSingleClick) {
|
|
||||||
viewer.screenSpaceEventHandler.setInputAction(
|
|
||||||
savedSingleClick,
|
|
||||||
Cesium.ScreenSpaceEventType.LEFT_CLICK,
|
|
||||||
);
|
|
||||||
savedSingleClick = null;
|
|
||||||
}
|
|
||||||
if (savedDoubleClick) {
|
|
||||||
viewer.screenSpaceEventHandler.setInputAction(
|
|
||||||
savedDoubleClick,
|
|
||||||
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
|
|
||||||
);
|
|
||||||
savedDoubleClick = null;
|
|
||||||
}
|
|
||||||
// Drop the keydown listener bound alongside this handler.
|
|
||||||
for (let i = domListeners.length - 1; i >= 0; i -= 1) {
|
for (let i = domListeners.length - 1; i >= 0; i -= 1) {
|
||||||
const [target, type, listener, options] = domListeners[i];
|
const [target, type, listener, options] = domListeners[i];
|
||||||
if (listener !== onKey) continue;
|
if (listener !== onKey) continue;
|
||||||
@@ -523,26 +504,15 @@ export function initDrawTool({
|
|||||||
return {
|
return {
|
||||||
active,
|
active,
|
||||||
destroyed,
|
destroyed,
|
||||||
sceneHandler: Boolean(handler),
|
sceneHandler: Boolean(unbindScene),
|
||||||
domListeners: domListeners.length,
|
domListeners: domListeners.length,
|
||||||
previewDataSources: countPreviewDataSources(viewer),
|
previewDataSources: countPreviewDataSources(viewer),
|
||||||
previewEntities: destroyed ? 0 : dataSource.entities.values.length,
|
previewEntities: destroyed ? 0 : dataSource.entities.values.length,
|
||||||
pointerOwner: pointerOwner(),
|
pointerOwner: pointerOwner(),
|
||||||
// The viewer's OWN click actions. While a session is open both are
|
// The viewer's own select and follow, which the input port runs: off
|
||||||
// borrowed (absent here); after it closes both are back. A harness can
|
// while a session is open, back on after it closes.
|
||||||
// compare the restored functions by identity with what it captured
|
stockSingleClick: inputFor(viewer).stockEnabled('select'),
|
||||||
// before, which is the only way to prove they were given back rather
|
stockDoubleClick: inputFor(viewer).stockEnabled('dblclickFollow'),
|
||||||
// than replaced.
|
|
||||||
stockSingleClick: Boolean(
|
|
||||||
viewer.screenSpaceEventHandler?.getInputAction?.(
|
|
||||||
Cesium.ScreenSpaceEventType.LEFT_CLICK,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
stockDoubleClick: Boolean(
|
|
||||||
viewer.screenSpaceEventHandler?.getInputAction?.(
|
|
||||||
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
/** Resolves once every deferred teardown step has run. */
|
/** Resolves once every deferred teardown step has run. */
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
releasePointer,
|
releasePointer,
|
||||||
resetPointerOwnership,
|
resetPointerOwnership,
|
||||||
} from '../data/inputOwnership.js';
|
} from '../data/inputOwnership.js';
|
||||||
|
import { fakeInput } from '../input/testDoubles.mjs';
|
||||||
|
|
||||||
/* ── DOM doubles ───────────────────────────────────────────────────────── */
|
/* ── DOM doubles ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -223,10 +224,13 @@ function fakeAnnotations() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
|
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
|
||||||
function harness({ navigation = null } = {}) {
|
function harness({ navigation = null, beforeInput = () => {} } = {}) {
|
||||||
resetPointerOwnership();
|
resetPointerOwnership();
|
||||||
const { byId, documentDouble } = fakeDom();
|
const { byId, documentDouble } = fakeDom();
|
||||||
const viewer = fakeViewer();
|
const viewer = fakeViewer();
|
||||||
|
// The viewer's own actions are bound before its input port takes them.
|
||||||
|
beforeInput(viewer);
|
||||||
|
const input = fakeInput(viewer);
|
||||||
const annotations = fakeAnnotations();
|
const annotations = fakeAnnotations();
|
||||||
const previousDocument = globalThis.document;
|
const previousDocument = globalThis.document;
|
||||||
const previousWindow = globalThis.window;
|
const previousWindow = globalThis.window;
|
||||||
@@ -256,7 +260,9 @@ function harness({ navigation = null } = {}) {
|
|||||||
controls,
|
controls,
|
||||||
/** A control's change, as the view sends it. */
|
/** A control's change, as the view sends it. */
|
||||||
act: (name, ...args) => controls.actions?.[name]?.(...args),
|
act: (name, ...args) => controls.actions?.[name]?.(...args),
|
||||||
|
input,
|
||||||
restore() {
|
restore() {
|
||||||
|
input.destroy();
|
||||||
resetPointerOwnership();
|
resetPointerOwnership();
|
||||||
if (previousDocument === undefined) delete globalThis.document;
|
if (previousDocument === undefined) delete globalThis.document;
|
||||||
else globalThis.document = previousDocument;
|
else globalThis.document = previousDocument;
|
||||||
@@ -335,30 +341,33 @@ test('a destroyed instance cannot free the pointer its replacement holds', async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('both stock viewer click actions are borrowed for the session and given back', async () => {
|
test('both stock viewer click actions are held off for the session and given back', async () => {
|
||||||
const h = harness();
|
const stockClick = () => 'viewer picks and selects';
|
||||||
|
const stockDouble = () => 'viewer tracks';
|
||||||
|
const h = harness({
|
||||||
|
beforeInput(viewer) {
|
||||||
|
viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
|
||||||
|
viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
|
||||||
|
},
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const stockClick = () => 'viewer picks and selects';
|
assert.equal(h.input.stockEnabled('select'), true);
|
||||||
const stockDouble = () => 'viewer tracks';
|
|
||||||
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
|
|
||||||
h.viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
|
|
||||||
|
|
||||||
h.act('toggleDraw');
|
h.act('toggleDraw');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
|
h.input.stockEnabled('select'),
|
||||||
undefined,
|
false,
|
||||||
"the viewer's own selecting click must not run while drawing",
|
"the viewer's own selecting click must not run while drawing",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(h.input.stockEnabled('dblclickFollow'), false);
|
||||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK),
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
h.act('toggleDraw');
|
h.act('toggleDraw');
|
||||||
|
assert.equal(h.input.stockEnabled('select'), true);
|
||||||
|
assert.equal(h.input.stockEnabled('dblclickFollow'), true);
|
||||||
|
h.input.destroy();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
|
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
|
||||||
stockClick,
|
stockClick,
|
||||||
'the exact function is restored, not a replacement',
|
'the exact function goes back to the viewer, not a replacement',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK),
|
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK),
|
||||||
@@ -468,7 +477,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
|
|||||||
assert.equal(isPointerFree(), true, 'the pointer is free');
|
assert.equal(isPointerFree(), true, 'the pointer is free');
|
||||||
const after = h.tool.diagnostics();
|
const after = h.tool.diagnostics();
|
||||||
assert.equal(after.destroyed, true);
|
assert.equal(after.destroyed, true);
|
||||||
assert.equal(after.sceneHandler, false, 'the Cesium handler is gone');
|
assert.equal(after.sceneHandler, false, 'the pointer subscriptions are gone');
|
||||||
assert.equal(after.domListeners, 0, 'every DOM listener came off');
|
assert.equal(after.domListeners, 0, 'every DOM listener came off');
|
||||||
assert.equal(h.documentDouble.listeners.length, 0, 'including the keydown');
|
assert.equal(h.documentDouble.listeners.length, 0, 'including the keydown');
|
||||||
assert.equal(h.controls.actions, null, 'and the controls are disconnected');
|
assert.equal(h.controls.actions, null, 'and the controls are disconnected');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createPinia } from 'pinia';
|
|||||||
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
||||||
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
||||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||||
|
import { bindFakeCesiumCredits } from '../testSupport/fakeCreditsPort.mjs';
|
||||||
|
|
||||||
/** A hybrid renderer whose screen board is presented to its own store. */
|
/** A hybrid renderer whose screen board is presented to its own store. */
|
||||||
function hybridWithBoard(viewer) {
|
function hybridWithBoard(viewer) {
|
||||||
@@ -110,7 +111,7 @@ function fakeViewer(lon, lat) {
|
|||||||
return {
|
return {
|
||||||
dataSources,
|
dataSources,
|
||||||
frames,
|
frames,
|
||||||
viewer: {
|
viewer: bindFakeCesiumCredits({
|
||||||
credits,
|
credits,
|
||||||
creditDisplay: {
|
creditDisplay: {
|
||||||
addStaticCredit: (credit) => credits.add(credit),
|
addStaticCredit: (credit) => credits.add(credit),
|
||||||
@@ -130,7 +131,7 @@ function fakeViewer(lon, lat) {
|
|||||||
return index >= 0;
|
return index >= 0;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,8 +541,9 @@ test('buildings share one probe per frame and preserve probe geometry, filtering
|
|||||||
const detail = parent.detail;
|
const detail = parent.detail;
|
||||||
assert.equal(detail.index, index);
|
assert.equal(detail.index, index);
|
||||||
assert.equal(detail.status, trace === firstTrace ? 'recorded' : 'missing');
|
assert.equal(detail.status, trace === firstTrace ? 'recorded' : 'missing');
|
||||||
|
// The fourth probe finds nothing and the fifth throws: both are misses.
|
||||||
assert.equal(detail.clampResult,
|
assert.equal(detail.clampResult,
|
||||||
trace === firstTrace && index === 4 ? 'threw' : 'returned');
|
trace === firstTrace && index !== 3 && index !== 4 ? 'hit' : 'missed');
|
||||||
assert.ok(Number.isFinite(detail.clampStart) && Number.isFinite(detail.clampEnd));
|
assert.ok(Number.isFinite(detail.clampStart) && Number.isFinite(detail.clampEnd));
|
||||||
assert.ok(parent.start <= detail.clampStart && detail.clampStart <= detail.clampEnd && detail.clampEnd <= parent.end);
|
assert.ok(parent.start <= detail.clampStart && detail.clampStart <= detail.clampEnd && detail.clampEnd <= parent.end);
|
||||||
if (index === 7) {
|
if (index === 7) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
|||||||
import { createAnnotationResolver } from './resolver.js';
|
import { createAnnotationResolver } from './resolver.js';
|
||||||
import { polygonsContain } from '../data/adminBoundaries.js';
|
import { polygonsContain } from '../data/adminBoundaries.js';
|
||||||
import { streetFootprint } from './outlineRungs.js';
|
import { streetFootprint } from './outlineRungs.js';
|
||||||
|
import { bindFakeCesiumCredits } from '../testSupport/fakeCreditsPort.mjs';
|
||||||
|
|
||||||
function viewerAt(lat, lon, height = 20_000) {
|
function viewerAt(lat, lon, height = 20_000) {
|
||||||
return {
|
return {
|
||||||
@@ -180,10 +181,10 @@ test('a geocoded city or neighborhood upgrades to the bundled outline', async ()
|
|||||||
const credits = [];
|
const credits = [];
|
||||||
const cancelled = await city.resolve(
|
const cancelled = await city.resolve(
|
||||||
'Austin',
|
'Austin',
|
||||||
{
|
bindFakeCesiumCredits({
|
||||||
...viewerAt(39, -100, 5_000_000),
|
...viewerAt(39, -100, 5_000_000),
|
||||||
creditDisplay: { addStaticCredit: (credit) => credits.push(credit) },
|
creditDisplay: { addStaticCredit: (credit) => credits.push(credit) },
|
||||||
},
|
}),
|
||||||
{ signal: controller.signal },
|
{ signal: controller.signal },
|
||||||
);
|
);
|
||||||
const pending = cancelled.resolveOutline();
|
const pending = cancelled.resolveOutline();
|
||||||
|
|||||||
+11
-18
@@ -45,6 +45,7 @@ import {
|
|||||||
import { createOutlineRungs } from './outlineRungs.js';
|
import { createOutlineRungs } from './outlineRungs.js';
|
||||||
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||||
|
import { heightsFor } from '../queries/heights.js';
|
||||||
|
|
||||||
/** Prepare only local dateline outlines for planar admission and centering. */
|
/** Prepare only local dateline outlines for planar admission and centering. */
|
||||||
function localOutlineFrame(fp) {
|
function localOutlineFrame(fp) {
|
||||||
@@ -2351,26 +2352,18 @@ export function createAnnotationResolver({
|
|||||||
function sampleGroundHeight(viewer, lon, lat) {
|
function sampleGroundHeight(viewer, lon, lat) {
|
||||||
const scene = viewer?.scene;
|
const scene = viewer?.scene;
|
||||||
if (!scene) return 0;
|
if (!scene) return 0;
|
||||||
try {
|
const heights = heightsFor(scene);
|
||||||
if (
|
// A miss is tiles not ready: fall through to the terrain, then 0.
|
||||||
scene.clampToHeightSupported &&
|
if (heights.supported().clamp) {
|
||||||
typeof scene.clampToHeight === 'function'
|
const surface = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
|
||||||
) {
|
const clamped = heights.clampNow(surface);
|
||||||
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
if (clamped) {
|
||||||
const surface = Cesium.Cartographic.toCartesian(carto);
|
const h = Cesium.Cartographic.fromCartesian(clamped)?.height;
|
||||||
const clamped = scene.clampToHeight(surface);
|
if (Number.isFinite(h)) return h;
|
||||||
if (clamped) {
|
|
||||||
const h = Cesium.Cartographic.fromCartesian(clamped).height;
|
|
||||||
if (Number.isFinite(h)) return h;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
/* tiles not ready — fall through */
|
|
||||||
}
|
}
|
||||||
const globeHeight = scene.globe?.getHeight?.(
|
const globeHeight = heights.now({ lon, lat }, { against: 'terrain' });
|
||||||
Cesium.Cartographic.fromDegrees(lon, lat),
|
return globeHeight !== null && globeHeight > 0 ? globeHeight : 0;
|
||||||
);
|
|
||||||
return Number.isFinite(globeHeight) && globeHeight > 0 ? globeHeight : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortLabel(formattedAddress) {
|
function shortLabel(formattedAddress) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { followedOf } from '../data/followed.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { getOverlayPaintRect } from '../overlays/worldOverlay.js';
|
import { getOverlayPaintRect } from '../overlays/worldOverlay.js';
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +9,7 @@ import { t } from '../i18n/index.js';
|
|||||||
import { nextTick } from 'vue';
|
import { nextTick } from 'vue';
|
||||||
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
||||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||||
|
import { heightsFor } from '../queries/heights.js';
|
||||||
|
|
||||||
/** A label whose detailed outline could not be resolved. */
|
/** A label whose detailed outline could not be resolved. */
|
||||||
function outlineUnavailableLabel(label) {
|
function outlineUnavailableLabel(label) {
|
||||||
@@ -141,13 +143,10 @@ export function createScreenAnnotationRenderer(
|
|||||||
return cached.h;
|
return cached.h;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (
|
const heights = heightsFor(scene);
|
||||||
scene.clampToHeightSupported &&
|
if (heights.supported().clamp) {
|
||||||
typeof scene.clampToHeight === 'function'
|
// A miss is tiles not ready.
|
||||||
) {
|
const c = heights.clampNow(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
|
||||||
const c = scene.clampToHeight(
|
|
||||||
Cesium.Cartesian3.fromDegrees(lon, lat, 0),
|
|
||||||
);
|
|
||||||
if (c) {
|
if (c) {
|
||||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
const h = Cesium.Cartographic.fromCartesian(c).height;
|
||||||
if (Number.isFinite(h) && h > -430 && h < 9000) {
|
if (Number.isFinite(h) && h > -430 && h < 9000) {
|
||||||
@@ -422,12 +421,10 @@ export function createScreenAnnotationRenderer(
|
|||||||
let top = painted?.y;
|
let top = painted?.y;
|
||||||
let bottom = painted ? painted.y + painted.h : undefined;
|
let bottom = painted ? painted.y + painted.h : undefined;
|
||||||
|
|
||||||
const ent = viewer.trackedEntity;
|
const followed = followedOf(viewer);
|
||||||
const now = Cesium.JulianDate.now();
|
const displayPosition = followed?.displayPosition;
|
||||||
const world =
|
const world =
|
||||||
typeof ent?.gevDisplayPosition === 'function'
|
typeof displayPosition === 'function' ? displayPosition() : null;
|
||||||
? ent.gevDisplayPosition()
|
|
||||||
: null;
|
|
||||||
const win = world
|
const win = world
|
||||||
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||||
scene,
|
scene,
|
||||||
@@ -436,13 +433,14 @@ export function createScreenAnnotationRenderer(
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Billboard box — centered on the anchor, magnified by scaleByDistance.
|
// Billboard box — centered on the anchor, magnified by scaleByDistance, from
|
||||||
const bb = ent?.billboard;
|
// the size the tracking layer recorded for its sprite.
|
||||||
if (bb && win && Number.isFinite(win.x) && Number.isFinite(win.y)) {
|
const sprite = followed?.sprite;
|
||||||
const baseW = bb.width?.getValue?.(now) ?? 28;
|
if (sprite && win && Number.isFinite(win.x) && Number.isFinite(win.y)) {
|
||||||
const baseH = bb.height?.getValue?.(now) ?? 28;
|
const baseW = sprite.width ?? 28;
|
||||||
|
const baseH = sprite.height ?? 28;
|
||||||
let scale = 1;
|
let scale = 1;
|
||||||
const sbd = bb.scaleByDistance?.getValue?.(now);
|
const sbd = sprite.scaleByDistance;
|
||||||
if (sbd)
|
if (sbd)
|
||||||
scale = nearFarValue(
|
scale = nearFarValue(
|
||||||
sbd,
|
sbd,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { recordWorkSlice } from '../sources/phaseTiming.js';
|
|||||||
import { ringCentroid } from '../sources/featureGeometry.js';
|
import { ringCentroid } from '../sources/featureGeometry.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||||
|
import { heightsFor } from '../queries/heights.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* World-space annotation renderer (Direction A).
|
* World-space annotation renderer (Direction A).
|
||||||
@@ -174,20 +175,16 @@ export function createWorldAnnotationRenderer(viewer) {
|
|||||||
const sliceStart = performance.now();
|
const sliceStart = performance.now();
|
||||||
let error;
|
let error;
|
||||||
try {
|
try {
|
||||||
if (
|
if (heightsFor(viewer.scene).supported().clamp) {
|
||||||
viewer.scene.clampToHeightSupported &&
|
|
||||||
typeof viewer.scene.clampToHeight === 'function'
|
|
||||||
) {
|
|
||||||
const [lon, lat] = task.points[task.index++];
|
const [lon, lat] = task.points[task.index++];
|
||||||
timing.probes += 1;
|
timing.probes += 1;
|
||||||
try {
|
try {
|
||||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
|
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
|
||||||
let c;
|
let c;
|
||||||
note?.('clampStart');
|
note?.('clampStart');
|
||||||
if (detail) detail.clampResult = 'threw';
|
|
||||||
try {
|
try {
|
||||||
c = viewer.scene.clampToHeight(position);
|
c = heightsFor(viewer.scene).clampNow(position);
|
||||||
if (detail) detail.clampResult = 'returned';
|
if (detail) detail.clampResult = c ? 'hit' : 'missed';
|
||||||
} finally {
|
} finally {
|
||||||
note?.('clampEnd');
|
note?.('clampEnd');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { createApplication } from './application.js';
|
import { createApplication } from './application.js';
|
||||||
import { installTrackpadPinchZoom } from './viewer.js';
|
import { installTrackpadPinchZoom } from '../render/cesium/ctrlWheelZoom.js';
|
||||||
|
|
||||||
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
|
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
|
||||||
function fixture(overrides = {}) {
|
function fixture(overrides = {}) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
|||||||
import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
||||||
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
|
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
|
||||||
import { createApplicationCables } from './layers/submarineCables.js';
|
import { createApplicationCables } from './layers/submarineCables.js';
|
||||||
|
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
|
||||||
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
||||||
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
||||||
import { localGeoJsonServices } from './localGeojsonServices.js';
|
import { localGeoJsonServices } from './localGeojsonServices.js';
|
||||||
@@ -169,6 +170,7 @@ export function createApplicationCatalog({
|
|||||||
clock: weatherClock,
|
clock: weatherClock,
|
||||||
}),
|
}),
|
||||||
createCyclonesLayer({ feed: sources.cyclones }),
|
createCyclonesLayer({ feed: sources.cyclones }),
|
||||||
|
...createOsmOverlayLayers(),
|
||||||
...createInfrastructureLayers(localGeoJsonServices),
|
...createInfrastructureLayers(localGeoJsonServices),
|
||||||
createApplicationCables({ source: sources.cables }),
|
createApplicationCables({ source: sources.cables }),
|
||||||
createApplicationFirms({
|
createApplicationFirms({
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
|||||||
signal: b.signal,
|
signal: b.signal,
|
||||||
surface: fixtureSurface(b.signal),
|
surface: fixtureSurface(b.signal),
|
||||||
});
|
});
|
||||||
assert.equal(first.layers.length, 33);
|
assert.equal(first.layers.length, 35);
|
||||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
first.metadata.find(({ id }) => id === 'local-adsb'),
|
first.metadata.find(({ id }) => id === 'local-adsb'),
|
||||||
@@ -155,7 +155,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
|
|||||||
surface: fixtureSurface(lifetime.signal),
|
surface: fixtureSurface(lifetime.signal),
|
||||||
};
|
};
|
||||||
const catalog = createApplicationCatalog(options);
|
const catalog = createApplicationCatalog(options);
|
||||||
assert.equal(catalog.layers.length, 33);
|
assert.equal(catalog.layers.length, 35);
|
||||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
catalogControlServices(catalog).streetLevelLayer,
|
catalogControlServices(catalog).streetLevelLayer,
|
||||||
|
|||||||
+13
-14
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { announceNavigationAuthority } from '../navigationPolicy.js';
|
import { announceNavigationAuthority } from '../navigationPolicy.js';
|
||||||
|
import { cameraOf } from '../render/cesium/camera.js';
|
||||||
import { annotationsFromParams, createView } from '../view/index.js';
|
import { annotationsFromParams, createView } from '../view/index.js';
|
||||||
|
|
||||||
const EMBED_VIEW_MESSAGE = 'gev:view';
|
const EMBED_VIEW_MESSAGE = 'gev:view';
|
||||||
@@ -147,21 +148,19 @@ const delay = (ms, signal) =>
|
|||||||
function flyTo(viewer, camera) {
|
function flyTo(viewer, camera) {
|
||||||
announceNavigationAuthority('embed-view');
|
announceNavigationAuthority('embed-view');
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
viewer.camera.flyTo({
|
cameraOf(viewer).flyTo(
|
||||||
destination: Cesium.Cartesian3.fromDegrees(
|
{ lon: camera.lon, lat: camera.lat, height: camera.altitude_m },
|
||||||
camera.lon,
|
{
|
||||||
camera.lat,
|
orientation: {
|
||||||
camera.altitude_m,
|
heading: Cesium.Math.toRadians(camera.heading_deg),
|
||||||
),
|
pitch: Cesium.Math.toRadians(camera.pitch_deg),
|
||||||
orientation: {
|
roll: 0,
|
||||||
heading: Cesium.Math.toRadians(camera.heading_deg),
|
},
|
||||||
pitch: Cesium.Math.toRadians(camera.pitch_deg),
|
durationS: FLIGHT_SECONDS,
|
||||||
roll: 0,
|
onComplete: () => resolve(true),
|
||||||
|
onCancel: () => resolve(false),
|
||||||
},
|
},
|
||||||
duration: FLIGHT_SECONDS,
|
);
|
||||||
complete: () => resolve(true),
|
|
||||||
cancel: () => resolve(false),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +13,7 @@ import { createView } from '../view/index.js';
|
|||||||
|
|
||||||
const fakeViewer = () => {
|
const fakeViewer = () => {
|
||||||
const flights = [];
|
const flights = [];
|
||||||
return {
|
return bindFakeCesiumCamera({
|
||||||
flights,
|
flights,
|
||||||
camera: {
|
camera: {
|
||||||
flyTo(options) {
|
flyTo(options) {
|
||||||
@@ -20,7 +21,7 @@ const fakeViewer = () => {
|
|||||||
options.complete();
|
options.complete();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
};
|
};
|
||||||
const fakeLayers = (enabled) => ({
|
const fakeLayers = (enabled) => ({
|
||||||
getAll: () =>
|
getAll: () =>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import { createFirmsHeatmapLayer as createLayer } from '../../layers/firms/index.js';
|
import { createFirmsHeatmapLayer as createLayer } from '../../layers/firms/index.js';
|
||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
import * as sprites from '../../data/spriteOrder.js';
|
import * as sprites from '../../data/spriteOrder.js';
|
||||||
@@ -29,8 +28,5 @@ export function createApplicationFirms({ surface, ...options }) {
|
|||||||
clearSource: overlays.clearOverlaySource,
|
clearSource: overlays.clearOverlaySource,
|
||||||
hitTest: overlays.hitTestWorldOverlay,
|
hitTest: overlays.hitTestWorldOverlay,
|
||||||
},
|
},
|
||||||
screenSpaceEventHandlerFactory:
|
|
||||||
options.screenSpaceEventHandlerFactory ??
|
|
||||||
((viewer) => new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js';
|
import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js';
|
||||||
import * as picking from '../../data/pickRegistry.js';
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
import * as overlays from '../../overlays/worldOverlay.js';
|
import * as overlays from '../../overlays/worldOverlay.js';
|
||||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
|
||||||
|
|
||||||
/** Wire the S2 Underground GhostMaps layer into the application catalog. */
|
/** Wire the S2 Underground GhostMaps layer into the application catalog. */
|
||||||
export function createApplicationGhostMaps(options) {
|
export function createApplicationGhostMaps(options) {
|
||||||
@@ -14,10 +12,7 @@ export function createApplicationGhostMaps(options) {
|
|||||||
hitTest: overlays.hitTestWorldOverlay,
|
hitTest: overlays.hitTestWorldOverlay,
|
||||||
},
|
},
|
||||||
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
||||||
screenSpaceEventHandlerFactory: (viewer) =>
|
|
||||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
|
||||||
picking,
|
picking,
|
||||||
pointer: { isPointerFree },
|
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import {
|
import {
|
||||||
createFirePerimetersLayer,
|
createFirePerimetersLayer,
|
||||||
createInciwebIndexSource,
|
createInciwebIndexSource,
|
||||||
@@ -6,7 +5,6 @@ import {
|
|||||||
} from '../../layers/perimeters/index.js';
|
} from '../../layers/perimeters/index.js';
|
||||||
import * as picking from '../../data/pickRegistry.js';
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
import * as overlays from '../../overlays/worldOverlay.js';
|
import * as overlays from '../../overlays/worldOverlay.js';
|
||||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
|
||||||
|
|
||||||
/** Wire WFIGS fire perimeters into the application catalog. */
|
/** Wire WFIGS fire perimeters into the application catalog. */
|
||||||
export function createApplicationFirePerimeters(options) {
|
export function createApplicationFirePerimeters(options) {
|
||||||
@@ -20,10 +18,7 @@ export function createApplicationFirePerimeters(options) {
|
|||||||
inciwebSource: createInciwebIndexSource(),
|
inciwebSource: createInciwebIndexSource(),
|
||||||
inciwebPublications: createInciwebPublicationSource(),
|
inciwebPublications: createInciwebPublicationSource(),
|
||||||
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
||||||
screenSpaceEventHandlerFactory: (viewer) =>
|
|
||||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
|
||||||
picking,
|
picking,
|
||||||
pointer: { isPointerFree },
|
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,6 @@ import { createKartaViewProvider } from '../../layers/streetLevel/providers/kart
|
|||||||
import { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js';
|
import { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js';
|
||||||
import * as sprites from '../../data/spriteOrder.js';
|
import * as sprites from '../../data/spriteOrder.js';
|
||||||
import * as picking from '../../data/pickRegistry.js';
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
import * as input from '../../data/inputOwnership.js';
|
|
||||||
import * as scenePick from '../../data/scenePick.js';
|
import * as scenePick from '../../data/scenePick.js';
|
||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
|
|
||||||
@@ -37,6 +36,6 @@ export function createApplicationStreetLevel({
|
|||||||
}) {
|
}) {
|
||||||
return createStreetLevelLayer({
|
return createStreetLevelLayer({
|
||||||
providers,
|
providers,
|
||||||
services: { sprites, picking, input, scenePick, render },
|
services: { sprites, picking, scenePick, render },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import { createSubmarineCableLayer } from '../../layers/submarineCables/index.js';
|
import { createSubmarineCableLayer } from '../../layers/submarineCables/index.js';
|
||||||
import { overlayHost } from './overlayHost.js';
|
import { overlayHost } from './overlayHost.js';
|
||||||
/** Wire cable source geometry to the application overlay host. */
|
/** Wire cable source geometry to the application overlay host. */
|
||||||
export function createApplicationCables(options) {
|
export function createApplicationCables(options) {
|
||||||
return createSubmarineCableLayer({
|
return createSubmarineCableLayer({
|
||||||
overlayHost,
|
overlayHost,
|
||||||
screenSpaceEventHandlerFactory: (canvas) =>
|
|
||||||
new Cesium.ScreenSpaceEventHandler(canvas),
|
|
||||||
mapStackEventTarget: typeof window !== 'undefined' ? window : null,
|
mapStackEventTarget: typeof window !== 'undefined' ? window : null,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
governorRequestRender,
|
governorRequestRender,
|
||||||
getRenderGovernorDiagnostics,
|
getRenderGovernorDiagnostics,
|
||||||
} from '../renderGovernor.js';
|
} from '../renderGovernor.js';
|
||||||
|
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
|
||||||
|
|
||||||
test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => {
|
test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => {
|
||||||
let resolveProvider;
|
let resolveProvider;
|
||||||
@@ -44,11 +45,11 @@ test('uninstall only releases the current viewer and makes later render requests
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
installRenderGovernor(viewer);
|
installRenderGovernor(framePortFor(viewer.scene));
|
||||||
holdContinuousRender('fixture');
|
holdContinuousRender('fixture');
|
||||||
uninstallRenderGovernor({});
|
uninstallRenderGovernor(framePortFor({}));
|
||||||
assert.equal(getRenderGovernorDiagnostics().installed, true);
|
assert.equal(getRenderGovernorDiagnostics().installed, true);
|
||||||
uninstallRenderGovernor(viewer);
|
uninstallRenderGovernor(framePortFor(viewer.scene));
|
||||||
const before = renders;
|
const before = renders;
|
||||||
governorRequestRender('late');
|
governorRequestRender('late');
|
||||||
assert.equal(renders, before);
|
assert.equal(renders, before);
|
||||||
|
|||||||
+53
-16
@@ -1,23 +1,32 @@
|
|||||||
import { createApplicationOperations } from './operations.js';
|
import { createApplicationOperations } from './operations.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import {
|
||||||
createApplicationViewer,
|
cesiumRenderer,
|
||||||
installTrackpadPinchZoom,
|
cesiumViewerOf,
|
||||||
} from '../app/viewer.js';
|
} from '../render/cesium/appRenderer.js';
|
||||||
import { registerDataCredits } from '../data/dataCredits.js';
|
import { registerDataCredits } from '../data/dataCredits.js';
|
||||||
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
|
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
|
||||||
import { MapStackController } from '../mapStackController.js';
|
import { MapStackController } from '../mapStackController.js';
|
||||||
import { loadPhotorealisticTileset } from '../mapStartup.js';
|
import { loadPhotorealisticTileset } from '../mapStartup.js';
|
||||||
import { initLogoGaze } from '../logoGaze.js';
|
import { initLogoGaze } from '../logoGaze.js';
|
||||||
|
import { inputFor } from '../input/dispatcher.js';
|
||||||
import {
|
import {
|
||||||
|
holdContinuousRender,
|
||||||
|
installRenderGovernor,
|
||||||
uninstallRenderGovernor,
|
uninstallRenderGovernor,
|
||||||
governorRequestRender,
|
governorRequestRender,
|
||||||
} from '../renderGovernor.js';
|
} from '../renderGovernor.js';
|
||||||
import { describeError } from './errors.js';
|
import { describeError } from './errors.js';
|
||||||
import { onLocaleChange, t } from '../i18n/index.js';
|
import { onLocaleChange, t } from '../i18n/index.js';
|
||||||
|
|
||||||
/** Construct the application globe using the caller's local configuration. */
|
/**
|
||||||
|
* Construct the application globe using the caller's local configuration.
|
||||||
|
* `renderer` is the `RendererPlugin` that draws it (default: Cesium). The
|
||||||
|
* application still runs on the Cesium viewer until its layers are ported,
|
||||||
|
* so a plugin whose renderer has none is refused.
|
||||||
|
*/
|
||||||
export async function createApplicationScene({
|
export async function createApplicationScene({
|
||||||
|
renderer: rendererPlugin = cesiumRenderer,
|
||||||
requestServices,
|
requestServices,
|
||||||
googleApiKey,
|
googleApiKey,
|
||||||
googleTokens = null,
|
googleTokens = null,
|
||||||
@@ -44,20 +53,41 @@ export async function createApplicationScene({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
loaderStatus.textContent = t('Configuring viewer...');
|
loaderStatus.textContent = t('Configuring viewer...');
|
||||||
// Provider attribution stays visible, including clean-view and recording.
|
// The scene still loads the tiles, so their credentials stay here.
|
||||||
const creditContainer = document.createElement('div');
|
const renderer = await rendererPlugin.create({
|
||||||
creditContainer.id = 'cesium-credits';
|
container: document.getElementById('cesiumContainer'),
|
||||||
document.body.appendChild(creditContainer);
|
signal,
|
||||||
defer(() => creditContainer.remove());
|
|
||||||
const viewer = createApplicationViewer({
|
|
||||||
container: 'cesiumContainer',
|
|
||||||
creditContainer,
|
|
||||||
});
|
});
|
||||||
|
const viewer = cesiumViewerOf(renderer);
|
||||||
|
if (!viewer) {
|
||||||
|
const refusal = new Error(
|
||||||
|
`The ${rendererPlugin.id} renderer cannot run the application yet`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
renderer.destroy();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Init] Refused renderer failed to destroy:', error);
|
||||||
|
}
|
||||||
|
throw refusal;
|
||||||
|
}
|
||||||
|
// The governor keeps its holds on the renderer's frame port. The loop
|
||||||
|
// draws every frame until the application's tools are up (they release
|
||||||
|
// 'boot'), as it did before anything could hold it.
|
||||||
|
installRenderGovernor(renderer.frame);
|
||||||
|
holdContinuousRender('boot');
|
||||||
defer(() => {
|
defer(() => {
|
||||||
uninstallRenderGovernor(viewer);
|
try {
|
||||||
if (!viewer.isDestroyed()) viewer.destroy();
|
uninstallRenderGovernor(renderer.frame);
|
||||||
|
} finally {
|
||||||
|
renderer.destroy();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
defer(installTrackpadPinchZoom(viewer));
|
// Before any layer binds a click: every layer and tool shares this one.
|
||||||
|
// Deferred after the viewer, so it is released before the viewer goes.
|
||||||
|
const input = inputFor(viewer);
|
||||||
|
defer(() => input.destroy());
|
||||||
|
// Ctrl+wheel and trackpad pinches zoom the globe, not the page.
|
||||||
|
renderer.input.ctrlWheelZoom = true;
|
||||||
registerDataCredits(viewer, credits);
|
registerDataCredits(viewer, credits);
|
||||||
configureCreditKeyboardAccess(document);
|
configureCreditKeyboardAccess(document);
|
||||||
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
||||||
@@ -128,5 +158,12 @@ export async function createApplicationScene({
|
|||||||
});
|
});
|
||||||
|
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
return { viewer, tileset, mapStackController, operations };
|
return {
|
||||||
|
renderer,
|
||||||
|
viewer,
|
||||||
|
tileset,
|
||||||
|
mapStackController,
|
||||||
|
operations,
|
||||||
|
input,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
|
|||||||
|
|
||||||
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||||
const actual = catalog({}, t);
|
const actual = catalog({}, t);
|
||||||
assert.equal(actual.layers.length, 33);
|
assert.equal(actual.layers.length, 35);
|
||||||
const manager = new LayerLifecycle(
|
const manager = new LayerLifecycle(
|
||||||
{},
|
{},
|
||||||
{ getSourceAvailability: actual.getSourceAvailability },
|
{ getSourceAvailability: actual.getSourceAvailability },
|
||||||
|
|||||||
@@ -1,25 +1,68 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { flyToAustin } from '../camera.js';
|
import { flyToAustin } from '../camera.js';
|
||||||
|
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||||
|
|
||||||
test('teardown before the initial camera delay prevents a late flight', (t) => {
|
test('teardown before the initial camera delay prevents a late flight', (t) => {
|
||||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
let flights = 0;
|
let flights = 0;
|
||||||
let cancelled = 0;
|
let cancelled = 0;
|
||||||
const stop = flyToAustin({
|
const stop = flyToAustin(
|
||||||
isDestroyed: () => false,
|
bindFakeCesiumCamera({
|
||||||
camera: {
|
isDestroyed: () => false,
|
||||||
setView() {},
|
camera: {
|
||||||
flyTo() {
|
setView() {},
|
||||||
flights++;
|
flyTo() {
|
||||||
|
flights++;
|
||||||
|
},
|
||||||
|
cancelFlight() {
|
||||||
|
cancelled++;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
cancelFlight() {
|
}),
|
||||||
cancelled++;
|
);
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
stop();
|
stop();
|
||||||
t.mock.timers.tick(1000);
|
t.mock.timers.tick(1000);
|
||||||
assert.equal(flights, 0);
|
assert.equal(flights, 0);
|
||||||
assert.equal(cancelled, 1);
|
assert.equal(cancelled, 0, 'no fly-in started, so none to cancel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('teardown cancels the fly-in, not a flight the user started since', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const cancelled = [];
|
||||||
|
let current = null;
|
||||||
|
const viewer = bindFakeCesiumCamera({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
camera: {
|
||||||
|
get _currentFlight() {
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
setView() {},
|
||||||
|
flyTo(options) {
|
||||||
|
current?.cancel();
|
||||||
|
current = {
|
||||||
|
name: options.duration === 4 ? 'fly-in' : 'user',
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
cancelFlight() {
|
||||||
|
cancelled.push(current?.name);
|
||||||
|
const flight = current;
|
||||||
|
current = null;
|
||||||
|
flight?.cancel();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const stop = flyToAustin(viewer);
|
||||||
|
t.mock.timers.tick(500);
|
||||||
|
stop();
|
||||||
|
assert.deepEqual(cancelled, ['fly-in']);
|
||||||
|
|
||||||
|
// A user flight replaces the fly-in; teardown leaves it alone.
|
||||||
|
const second = flyToAustin(viewer);
|
||||||
|
t.mock.timers.tick(500);
|
||||||
|
viewer.camera.flyTo({ duration: 2 });
|
||||||
|
second();
|
||||||
|
assert.deepEqual(cancelled, ['fly-in']);
|
||||||
|
assert.equal(current.name, 'user');
|
||||||
});
|
});
|
||||||
+15
-14
@@ -1,3 +1,4 @@
|
|||||||
|
import { onFollowChanged } from '../data/followed.js';
|
||||||
import { SceneDirector } from '../scenes/director.js';
|
import { SceneDirector } from '../scenes/director.js';
|
||||||
import { initAnnotations } from '../annotations/index.js';
|
import { initAnnotations } from '../annotations/index.js';
|
||||||
import { initDrawTool } from '../annotations/drawTool.js';
|
import { initDrawTool } from '../annotations/drawTool.js';
|
||||||
@@ -11,12 +12,13 @@ import { installViews, isEmbeddedInline } from './embed.js';
|
|||||||
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
|
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
|
||||||
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
|
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
|
||||||
import {
|
import {
|
||||||
installRenderGovernor,
|
|
||||||
getRenderGovernorDiagnostics,
|
getRenderGovernorDiagnostics,
|
||||||
governorRequestRender,
|
governorRequestRender,
|
||||||
holdContinuousRender,
|
holdContinuousRender,
|
||||||
releaseContinuousRender,
|
releaseContinuousRender,
|
||||||
} from '../renderGovernor.js';
|
} from '../renderGovernor.js';
|
||||||
|
import { entityRecord, readEntityRecord } from '../data/entityRecords.js';
|
||||||
|
import { overlayHost } from './layers/overlayHost.js';
|
||||||
|
|
||||||
/** Attach scene tools, rendering listeners and the application debug handle. */
|
/** Attach scene tools, rendering listeners and the application debug handle. */
|
||||||
export function createApplicationTools({
|
export function createApplicationTools({
|
||||||
@@ -32,11 +34,11 @@ export function createApplicationTools({
|
|||||||
signal,
|
signal,
|
||||||
defer,
|
defer,
|
||||||
}) {
|
}) {
|
||||||
const { viewer, tileset, mapStackController, operations } = scene;
|
const { viewer, renderer, tileset, mapStackController, operations } = scene;
|
||||||
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
|
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
|
||||||
const { dataManager } = data;
|
const { dataManager } = data;
|
||||||
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
|
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
|
||||||
dataPacks: sceneDataPacks,
|
dataPacks: { overlayHost, ...sceneDataPacks },
|
||||||
isMapStackAvailable: (id) =>
|
isMapStackAvailable: (id) =>
|
||||||
mapStackController?.isStackAvailable(id) === true,
|
mapStackController?.isStackAvailable(id) === true,
|
||||||
});
|
});
|
||||||
@@ -125,10 +127,10 @@ export function createApplicationTools({
|
|||||||
}
|
}
|
||||||
if (startChrome)
|
if (startChrome)
|
||||||
defer(startChrome({ loadingScreen, styleManager, dataManager, signal }));
|
defer(startChrome({ loadingScreen, styleManager, dataManager, signal }));
|
||||||
// Idle render governor: flips the scene into requestRenderMode whenever
|
// Idle render governor: the loop draws on demand whenever nothing animates
|
||||||
// nothing animates per frame. Installed AFTER every module above has had
|
// per frame. It goes idle only now, after every module above has had its
|
||||||
// its chance to register pre-install holds. (perf wave 2)
|
// chance to hold. (perf wave 2)
|
||||||
installRenderGovernor(viewer);
|
releaseContinuousRender('boot');
|
||||||
// Cesium keeps a turned-off layer's draw commands (and so all its entities)
|
// Cesium keeps a turned-off layer's draw commands (and so all its entities)
|
||||||
// alive in reused per-pass arrays; trim them after each disable.
|
// alive in reused per-pass arrays; trim them after each disable.
|
||||||
defer(
|
defer(
|
||||||
@@ -145,12 +147,10 @@ export function createApplicationTools({
|
|||||||
|
|
||||||
// The follow camera recomputes the tracked target's dead-reckon position
|
// The follow camera recomputes the tracked target's dead-reckon position
|
||||||
// every frame — tracking anything is a per-frame animation. (perf wave 2)
|
// every frame — tracking anything is a per-frame animation. (perf wave 2)
|
||||||
const removeTrackingListener = viewer.trackedEntityChanged.addEventListener(
|
const removeTrackingListener = onFollowChanged(viewer, (followed) => {
|
||||||
() => {
|
if (followed) holdContinuousRender('tracked-entity');
|
||||||
if (viewer.trackedEntity) holdContinuousRender('tracked-entity');
|
else releaseContinuousRender('tracked-entity');
|
||||||
else releaseContinuousRender('tracked-entity');
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Hidden-state suspension (perf wave 2): when the window/tab is hidden,
|
// Hidden-state suspension (perf wave 2): when the window/tab is hidden,
|
||||||
// stop the default render loop outright — a hidden canvas repaints for
|
// stop the default render loop outright — a hidden canvas repaints for
|
||||||
@@ -161,7 +161,7 @@ export function createApplicationTools({
|
|||||||
// A panel's host may report it hidden while it is on screen; the panel
|
// A panel's host may report it hidden while it is on screen; the panel
|
||||||
// keeps drawing itself (see keepPanelRendering in embed.js).
|
// keeps drawing itself (see keepPanelRendering in embed.js).
|
||||||
const hidden = document.hidden && !isEmbeddedInline();
|
const hidden = document.hidden && !isEmbeddedInline();
|
||||||
viewer.useDefaultRenderLoop = !hidden;
|
renderer.frame.suspend(hidden);
|
||||||
cockpitCloudEffects?.setSuspended?.(hidden);
|
cockpitCloudEffects?.setSuspended?.(hidden);
|
||||||
if (!hidden) {
|
if (!hidden) {
|
||||||
data.presentation.flushVisible();
|
data.presentation.flushVisible();
|
||||||
@@ -194,6 +194,7 @@ export function createApplicationTools({
|
|||||||
getRenderGovernorDiagnostics,
|
getRenderGovernorDiagnostics,
|
||||||
surfaceServices: operations.surface,
|
surfaceServices: operations.surface,
|
||||||
requestRender: governorRequestRender,
|
requestRender: governorRequestRender,
|
||||||
|
entityRecords: { read: readEntityRecord, write: entityRecord },
|
||||||
};
|
};
|
||||||
const debug = window.__godsEyeView;
|
const debug = window.__godsEyeView;
|
||||||
defer(() => {
|
defer(() => {
|
||||||
|
|||||||
@@ -2,107 +2,6 @@ import * as Cesium from 'cesium';
|
|||||||
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
|
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
|
||||||
import { StarrySky } from '../sky/starrySky.js';
|
import { StarrySky } from '../sky/starrySky.js';
|
||||||
|
|
||||||
const PINCH_ZOOM_MULTIPLIER = 8;
|
|
||||||
const MAX_PINCH_PIXEL_DELTA = 120;
|
|
||||||
|
|
||||||
function boundedPinchDelta(delta) {
|
|
||||||
if (!Number.isFinite(delta) || delta === 0) return delta;
|
|
||||||
return (
|
|
||||||
Math.sign(delta) *
|
|
||||||
Math.min(Math.abs(delta) * PINCH_ZOOM_MULTIPLIER, MAX_PINCH_PIXEL_DELTA)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add browser trackpad pinch to Cesium's zoom inputs and return its disposer.
|
|
||||||
* Browsers expose this gesture as a small pixel-mode Ctrl+wheel event.
|
|
||||||
*/
|
|
||||||
export function installTrackpadPinchZoom(
|
|
||||||
viewer,
|
|
||||||
{ createWheelEvent = (type, init) => new WheelEvent(type, init) } = {},
|
|
||||||
) {
|
|
||||||
const controller = viewer?.scene?.screenSpaceCameraController;
|
|
||||||
const container = viewer?.container;
|
|
||||||
const canvas = viewer?.canvas;
|
|
||||||
if (!controller || !container || !canvas)
|
|
||||||
throw new TypeError('A complete Cesium viewer is required');
|
|
||||||
|
|
||||||
const originalZoomEventTypes = controller.zoomEventTypes;
|
|
||||||
const zoomEventTypes = Array.isArray(originalZoomEventTypes)
|
|
||||||
? originalZoomEventTypes
|
|
||||||
: originalZoomEventTypes === undefined
|
|
||||||
? []
|
|
||||||
: [originalZoomEventTypes];
|
|
||||||
const alreadyHandlesControlWheel = zoomEventTypes.some(
|
|
||||||
(binding) =>
|
|
||||||
binding?.eventType === Cesium.CameraEventType.WHEEL &&
|
|
||||||
binding?.modifier === Cesium.KeyboardEventModifier.CTRL,
|
|
||||||
);
|
|
||||||
const configuredZoomEventTypes = alreadyHandlesControlWheel
|
|
||||||
? originalZoomEventTypes
|
|
||||||
: [
|
|
||||||
...zoomEventTypes,
|
|
||||||
{
|
|
||||||
eventType: Cesium.CameraEventType.WHEEL,
|
|
||||||
modifier: Cesium.KeyboardEventModifier.CTRL,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
if (!alreadyHandlesControlWheel)
|
|
||||||
controller.zoomEventTypes = configuredZoomEventTypes;
|
|
||||||
|
|
||||||
const relayedEvents = new WeakSet();
|
|
||||||
const relayPinch = (event) => {
|
|
||||||
if (
|
|
||||||
!event.ctrlKey ||
|
|
||||||
relayedEvents.has(event) ||
|
|
||||||
event.deltaMode !== 0 ||
|
|
||||||
!Number.isFinite(event.deltaY) ||
|
|
||||||
event.deltaY === 0
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
let relayed;
|
|
||||||
try {
|
|
||||||
relayed = createWheelEvent('wheel', {
|
|
||||||
deltaX: event.deltaX,
|
|
||||||
deltaY: boundedPinchDelta(event.deltaY),
|
|
||||||
deltaZ: event.deltaZ,
|
|
||||||
deltaMode: event.deltaMode,
|
|
||||||
screenX: event.screenX,
|
|
||||||
screenY: event.screenY,
|
|
||||||
clientX: event.clientX,
|
|
||||||
clientY: event.clientY,
|
|
||||||
ctrlKey: true,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
view: globalThis.window,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// The registered Ctrl+wheel binding can still consume the original.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
relayedEvents.add(relayed);
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
canvas.dispatchEvent(relayed);
|
|
||||||
};
|
|
||||||
container.addEventListener('wheel', relayPinch, {
|
|
||||||
capture: true,
|
|
||||||
passive: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let active = true;
|
|
||||||
return () => {
|
|
||||||
if (!active) return;
|
|
||||||
active = false;
|
|
||||||
container.removeEventListener('wheel', relayPinch, true);
|
|
||||||
if (
|
|
||||||
!alreadyHandlesControlWheel &&
|
|
||||||
controller.zoomEventTypes === configuredZoomEventTypes
|
|
||||||
)
|
|
||||||
controller.zoomEventTypes = originalZoomEventTypes;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cesium.Viewer options of the standard globe viewer. */
|
/** Cesium.Viewer options of the standard globe viewer. */
|
||||||
export function applicationViewerOptions(creditContainer) {
|
export function applicationViewerOptions(creditContainer) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
+16
-18
@@ -1,36 +1,34 @@
|
|||||||
import * as Cesium from 'cesium';
|
import { toRadians } from './geo/index.js';
|
||||||
|
import { cameraOf } from './render/rendererOf.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set camera to Austin on load with a cinematic fly-in.
|
* Set camera to Austin on load with a cinematic fly-in.
|
||||||
* @returns {Function} Cancels the pending or active startup flight.
|
* @returns {Function} Cancels the pending or active startup flight.
|
||||||
*/
|
*/
|
||||||
export function flyToAustin(viewer) {
|
export function flyToAustin(viewer) {
|
||||||
|
const camera = cameraOf(viewer);
|
||||||
// Start from a high altitude, then fly down
|
// Start from a high altitude, then fly down
|
||||||
viewer.camera.setView({
|
camera.setView({
|
||||||
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 25000),
|
destination: { lon: -97.7431, lat: 30.2672, height: 25000 },
|
||||||
orientation: {
|
orientation: { heading: 0, pitch: toRadians(-90), roll: 0 },
|
||||||
heading: Cesium.Math.toRadians(0),
|
|
||||||
pitch: Cesium.Math.toRadians(-90),
|
|
||||||
roll: 0.0,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cinematic fly-in after a brief pause
|
// Cinematic fly-in after a brief pause
|
||||||
|
let flight = null;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (viewer.isDestroyed()) return;
|
if (viewer.isDestroyed()) return;
|
||||||
viewer.camera.flyTo({
|
flight = camera.flyTo(
|
||||||
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 600),
|
{ lon: -97.7431, lat: 30.2672, height: 600 },
|
||||||
orientation: {
|
{
|
||||||
heading: Cesium.Math.toRadians(15),
|
orientation: { heading: toRadians(15), pitch: toRadians(-30), roll: 0 },
|
||||||
pitch: Cesium.Math.toRadians(-30),
|
durationS: 4.0,
|
||||||
roll: 0.0,
|
easing: 'cubicInOut',
|
||||||
},
|
},
|
||||||
duration: 4.0,
|
);
|
||||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
|
||||||
});
|
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
if (!viewer.isDestroyed()) viewer.camera.cancelFlight();
|
// Only the fly-in: a flight the user started since is theirs.
|
||||||
|
if (!viewer.isDestroyed()) flight?.cancel();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
|
||||||
import { GLOBE_VIEW } from './locations.js';
|
import { GLOBE_VIEW } from './locations.js';
|
||||||
import { projectEarthDiscToViewport } from './celestialRing.js';
|
import { projectEarthDiscToViewport } from './celestialRing.js';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
@@ -146,13 +147,13 @@ function harness(
|
|||||||
roll: radians(roll),
|
roll: radians(roll),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const viewer = {
|
const viewer = bindFakeCesiumCamera({
|
||||||
camera,
|
camera,
|
||||||
scene,
|
scene,
|
||||||
trackedEntity: undefined,
|
trackedEntity: undefined,
|
||||||
trackedEntityChanged: new Cesium.Event(),
|
trackedEntityChanged: new Cesium.Event(),
|
||||||
clock: { onTick: new Cesium.Event() },
|
clock: { onTick: new Cesium.Event() },
|
||||||
};
|
});
|
||||||
const target = Cesium.Cartesian3.fromDegrees(
|
const target = Cesium.Cartesian3.fromDegrees(
|
||||||
longitudeDeg,
|
longitudeDeg,
|
||||||
latitude,
|
latitude,
|
||||||
@@ -288,9 +289,12 @@ test('replaced viewer releases the old tracking listener without duplicate subsc
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
const id = activeCameraMotionId();
|
const id = activeCameraMotionId();
|
||||||
first.viewer.trackedEntityChanged.raiseEvent({ id: 'stale-aircraft' });
|
// As Cesium's setter does: the value changes, then the event fires.
|
||||||
|
first.viewer.trackedEntity = { id: 'stale-aircraft' };
|
||||||
|
first.viewer.trackedEntityChanged.raiseEvent(first.viewer.trackedEntity);
|
||||||
assert.equal(activeCameraMotionId(), id);
|
assert.equal(activeCameraMotionId(), id);
|
||||||
next.viewer.trackedEntityChanged.raiseEvent({ id: 'new-aircraft' });
|
next.viewer.trackedEntity = { id: 'new-aircraft' };
|
||||||
|
next.viewer.trackedEntityChanged.raiseEvent(next.viewer.trackedEntity);
|
||||||
assert.equal(getActiveCameraMotion(), null);
|
assert.equal(getActiveCameraMotion(), null);
|
||||||
assert.equal(hasRenderHold(), false);
|
assert.equal(hasRenderHold(), false);
|
||||||
});
|
});
|
||||||
@@ -597,7 +601,7 @@ for (const mode of ['once', 'continuous']) {
|
|||||||
|
|
||||||
test('direct globe rotation refuses a tracked camera without disturbing its reference frame', (t) => {
|
test('direct globe rotation refuses a tracked camera without disturbing its reference frame', (t) => {
|
||||||
const { viewer, camera, target } = harness(t);
|
const { viewer, camera, target } = harness(t);
|
||||||
viewer.trackedEntity = { id: 'tracked-aircraft' };
|
viewer.trackedEntity = { id: 'tracked-aircraft', name: 'tracked-aircraft' };
|
||||||
camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-40), 1_000));
|
camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-40), 1_000));
|
||||||
const before = pose(camera);
|
const before = pose(camera);
|
||||||
const transform = Cesium.Matrix4.clone(camera.transform);
|
const transform = Cesium.Matrix4.clone(camera.transform);
|
||||||
|
|||||||
+81
-49
@@ -14,8 +14,12 @@
|
|||||||
* lifts the eye if it sits below a usable clearance. Heading and pitch are
|
* lifts the eye if it sits below a usable clearance. Heading and pitch are
|
||||||
* held, so the correction reads as the shot settling.
|
* held, so the correction reads as the shot settling.
|
||||||
*/
|
*/
|
||||||
|
import { onFollowChanged } from './data/followed.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { ownCameraArrival } from './data/cameraArrival.js';
|
import { ownCameraArrival } from './data/cameraArrival.js';
|
||||||
|
import { heightsFor } from './queries/heights.js';
|
||||||
|
import { cameraOf } from './render/cesium/camera.js';
|
||||||
|
import { cameraFlightActive } from './render/cesium/internals.js';
|
||||||
|
|
||||||
/** Minimum eye height above the rendered surface for a usable view. */
|
/** Minimum eye height above the rendered surface for a usable view. */
|
||||||
export const MIN_EYE_CLEARANCE_M = 120;
|
export const MIN_EYE_CLEARANCE_M = 120;
|
||||||
@@ -24,6 +28,11 @@ const GUARD_ATTEMPTS = 8;
|
|||||||
const GUARD_INTERVAL_MS = 600;
|
const GUARD_INTERVAL_MS = 600;
|
||||||
/** Ignore sub-metre noise rather than nudging the camera forever. */
|
/** Ignore sub-metre noise rather than nudging the camera forever. */
|
||||||
const GUARD_EPSILON_M = 2;
|
const GUARD_EPSILON_M = 2;
|
||||||
|
/** Eye travel (m) and turn (cosine) that mean another owner moved the camera. */
|
||||||
|
const MOVED_M = 0.01;
|
||||||
|
const TURNED_COS = 1 - 1e-9;
|
||||||
|
|
||||||
|
const groundScratch = new Cesium.Cartesian3();
|
||||||
|
|
||||||
const probeScratch = new Cesium.Cartographic();
|
const probeScratch = new Cesium.Cartographic();
|
||||||
|
|
||||||
@@ -37,22 +46,60 @@ const probeScratch = new Cesium.Cartographic();
|
|||||||
function sampleSurfaceM(scene, cells) {
|
function sampleSurfaceM(scene, cells) {
|
||||||
let heightM = Number.NaN;
|
let heightM = Number.NaN;
|
||||||
let sampled = 0;
|
let sampled = 0;
|
||||||
if (typeof scene?.sampleHeight !== 'function') return { heightM, sampled };
|
if (!scene) return { heightM, sampled };
|
||||||
|
const heights = heightsFor(scene);
|
||||||
for (const cell of cells) {
|
for (const cell of cells) {
|
||||||
try {
|
// A miss is a cell whose tiles are not ready.
|
||||||
const height = scene.sampleHeight(
|
const height = heights.now(
|
||||||
Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch),
|
Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch),
|
||||||
);
|
);
|
||||||
if (!Number.isFinite(height)) continue;
|
if (height === null) continue;
|
||||||
sampled += 1;
|
sampled += 1;
|
||||||
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
|
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
|
||||||
} catch {
|
|
||||||
/* tiles not ready for this cell */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { heightM, sampled };
|
return { heightM, sampled };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The eye's ground point (height 0), its height, and which way it looks. */
|
||||||
|
function poseOf(camera) {
|
||||||
|
const carto =
|
||||||
|
camera?.positionWC && Cesium.Cartographic.fromCartesian(camera.positionWC);
|
||||||
|
if (!carto || !camera.directionWC || !camera.upWC) return null;
|
||||||
|
return {
|
||||||
|
ground: Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude),
|
||||||
|
height: carto.height,
|
||||||
|
direction: Cesium.Cartesian3.clone(camera.directionWC),
|
||||||
|
up: Cesium.Cartesian3.clone(camera.upWC),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether another owner has moved the camera off `pose`: across the ground,
|
||||||
|
* down, or around. A pure rise is not one: Cesium's terrain collision lifts a
|
||||||
|
* buried eye straight up as tiles load, which is the case the guard exists
|
||||||
|
* for, so `pose` follows the highest eye seen and the guard keeps measuring.
|
||||||
|
*/
|
||||||
|
function moved(camera, pose) {
|
||||||
|
const carto = Cesium.Cartographic.fromCartesian(camera.positionWC);
|
||||||
|
if (!carto) return true;
|
||||||
|
const ground = Cesium.Cartesian3.fromRadians(
|
||||||
|
carto.longitude,
|
||||||
|
carto.latitude,
|
||||||
|
0,
|
||||||
|
undefined,
|
||||||
|
groundScratch,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
Cesium.Cartesian3.distance(ground, pose.ground) > MOVED_M ||
|
||||||
|
carto.height < pose.height - MOVED_M ||
|
||||||
|
Cesium.Cartesian3.dot(camera.directionWC, pose.direction) < TURNED_COS ||
|
||||||
|
Cesium.Cartesian3.dot(camera.upWC, pose.up) < TURNED_COS
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
pose.height = Math.max(pose.height, carto.height);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far the camera must rise to clear the ground, given a measured surface.
|
* How far the camera must rise to clear the ground, given a measured surface.
|
||||||
* Pure so the policy is testable without a scene.
|
* Pure so the policy is testable without a scene.
|
||||||
@@ -76,8 +123,10 @@ export function groundClearanceDeficitM(
|
|||||||
* Watch an arrival and lift the camera once the real surface can be measured.
|
* Watch an arrival and lift the camera once the real surface can be measured.
|
||||||
*
|
*
|
||||||
* Stops quietly on a clear view, exhausted attempts, the user taking the
|
* Stops quietly on a clear view, exhausted attempts, the user taking the
|
||||||
* controls (pointer or wheel on the canvas), or `isStale()` reporting that a
|
* controls (pointer or wheel on the canvas), `isStale()` reporting that a
|
||||||
* newer arrival owns the camera.
|
* newer arrival owns the camera, or any other caller moving the camera or
|
||||||
|
* starting a flight in the meantime: the guard only ever corrects the pose it
|
||||||
|
* was started on.
|
||||||
* @param {Cesium.Viewer} viewer Live viewer.
|
* @param {Cesium.Viewer} viewer Live viewer.
|
||||||
* @param {{lat: number, lon: number}} at Destination being framed.
|
* @param {{lat: number, lon: number}} at Destination being framed.
|
||||||
* @param {{clearanceM?: number, attempts?: number, intervalMs?: number,
|
* @param {{clearanceM?: number, attempts?: number, intervalMs?: number,
|
||||||
@@ -95,6 +144,10 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
|
|||||||
if (!viewer?.scene || !Number.isFinite(at?.lat) || !Number.isFinite(at?.lon))
|
if (!viewer?.scene || !Number.isFinite(at?.lat) || !Number.isFinite(at?.lon))
|
||||||
return () => {};
|
return () => {};
|
||||||
|
|
||||||
|
// Without a readable pose the guard could not tell a later owner's move.
|
||||||
|
const arrival = poseOf(viewer.camera);
|
||||||
|
if (!arrival) return () => {};
|
||||||
|
|
||||||
let timer = null;
|
let timer = null;
|
||||||
let done = false;
|
let done = false;
|
||||||
const canvas = viewer.scene.canvas;
|
const canvas = viewer.scene.canvas;
|
||||||
@@ -112,32 +165,7 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
|
|||||||
release();
|
release();
|
||||||
};
|
};
|
||||||
release = ownCameraArrival(viewer, stop);
|
release = ownCameraArrival(viewer, stop);
|
||||||
// Direct camera API callers (including duration-zero flights) must yield
|
removers.push(onFollowChanged(viewer, stop));
|
||||||
// before moving the eye. Restore the exact method ownership on completion.
|
|
||||||
for (const name of [
|
|
||||||
'flyTo',
|
|
||||||
'flyToBoundingSphere',
|
|
||||||
'setView',
|
|
||||||
'lookAt',
|
|
||||||
'cancelFlight',
|
|
||||||
]) {
|
|
||||||
const camera = viewer.camera;
|
|
||||||
const original = camera[name];
|
|
||||||
if (typeof original !== 'function') continue;
|
|
||||||
const descriptor = Object.getOwnPropertyDescriptor(camera, name);
|
|
||||||
const wrapped = function (...args) {
|
|
||||||
stop();
|
|
||||||
return original.apply(this, args);
|
|
||||||
};
|
|
||||||
camera[name] = wrapped;
|
|
||||||
removers.push(() => {
|
|
||||||
if (camera[name] !== wrapped) return;
|
|
||||||
if (descriptor) Object.defineProperty(camera, name, descriptor);
|
|
||||||
else delete camera[name];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const removeTracking = viewer.trackedEntityChanged?.addEventListener(stop);
|
|
||||||
if (removeTracking) removers.push(removeTracking);
|
|
||||||
documentRef?.addEventListener?.('keydown', stop, true);
|
documentRef?.addEventListener?.('keydown', stop, true);
|
||||||
// A manual gesture ends the guard, as it interrupts camera motion elsewhere.
|
// A manual gesture ends the guard, as it interrupts camera motion elsewhere.
|
||||||
for (const type of ['pointerdown', 'wheel'])
|
for (const type of ['pointerdown', 'wheel'])
|
||||||
@@ -147,8 +175,10 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
|
|||||||
const attempt = () => {
|
const attempt = () => {
|
||||||
if (done) return;
|
if (done) return;
|
||||||
if (isStale() || viewer.isDestroyed?.()) return stop();
|
if (isStale() || viewer.isDestroyed?.()) return stop();
|
||||||
remaining -= 1;
|
|
||||||
const camera = viewer.camera;
|
const camera = viewer.camera;
|
||||||
|
// Another caller owns the camera now; lifting would undo its move.
|
||||||
|
if (cameraFlightActive(camera) || moved(camera, arrival)) return stop();
|
||||||
|
remaining -= 1;
|
||||||
const carto = camera.positionCartographic;
|
const carto = camera.positionCartographic;
|
||||||
// The eye must clear both the framed target and the ground beneath it.
|
// The eye must clear both the framed target and the ground beneath it.
|
||||||
const below = {
|
const below = {
|
||||||
@@ -167,20 +197,22 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
|
|||||||
);
|
);
|
||||||
if (deficit > 0) {
|
if (deficit > 0) {
|
||||||
stop();
|
stop();
|
||||||
camera.flyTo({
|
cameraOf(viewer).flyTo(
|
||||||
destination: Cesium.Cartesian3.fromRadians(
|
Cesium.Cartesian3.fromRadians(
|
||||||
carto.longitude,
|
carto.longitude,
|
||||||
carto.latitude,
|
carto.latitude,
|
||||||
carto.height + deficit,
|
carto.height + deficit,
|
||||||
),
|
),
|
||||||
orientation: {
|
{
|
||||||
heading: camera.heading,
|
orientation: {
|
||||||
pitch: camera.pitch,
|
heading: camera.heading,
|
||||||
roll: camera.roll,
|
pitch: camera.pitch,
|
||||||
|
roll: camera.roll,
|
||||||
|
},
|
||||||
|
durationS: 0.9,
|
||||||
|
easing: 'cubicInOut',
|
||||||
},
|
},
|
||||||
duration: 0.9,
|
);
|
||||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
onLift?.(deficit);
|
onLift?.(deficit);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+146
-22
@@ -3,6 +3,7 @@
|
|||||||
// Arrival framing is computed before the destination's tiles exist, so it
|
// Arrival framing is computed before the destination's tiles exist, so it
|
||||||
// works from a predicted ground height. The guard measures the real surface
|
// works from a predicted ground height. The guard measures the real surface
|
||||||
// after arrival and lifts the eye; these tests pin when it acts.
|
// after arrival and lifts the eye; these tests pin when it acts.
|
||||||
|
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
@@ -49,8 +50,12 @@ test('the clearance frames a subject without turning into an overflight', () =>
|
|||||||
|
|
||||||
function fakeViewer({ cameraHeightM, surfaceM }) {
|
function fakeViewer({ cameraHeightM, surfaceM }) {
|
||||||
const flights = [];
|
const flights = [];
|
||||||
const position = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, cameraHeightM);
|
const position = Cesium.Cartographic.fromDegrees(
|
||||||
return {
|
-97.7603,
|
||||||
|
30.3141,
|
||||||
|
cameraHeightM,
|
||||||
|
);
|
||||||
|
return bindFakeCesiumCamera({
|
||||||
flights,
|
flights,
|
||||||
scene: {
|
scene: {
|
||||||
canvas: null,
|
canvas: null,
|
||||||
@@ -59,12 +64,19 @@ function fakeViewer({ cameraHeightM, surfaceM }) {
|
|||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
positionCartographic: position,
|
positionCartographic: position,
|
||||||
|
positionWC: Cesium.Cartographic.toCartesian(position),
|
||||||
|
directionWC: new Cesium.Cartesian3(0, 0, -1),
|
||||||
|
upWC: new Cesium.Cartesian3(0, 1, 0),
|
||||||
heading: 0.5,
|
heading: 0.5,
|
||||||
pitch: -0.55,
|
pitch: -0.55,
|
||||||
roll: 0,
|
roll: 0,
|
||||||
flyTo: (options) => flights.push(options),
|
// As Cesium does: a flight is under way until it completes.
|
||||||
|
flyTo(options) {
|
||||||
|
flights.push(options);
|
||||||
|
this._currentFlight = {};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('the guard lifts a buried arrival once the surface answers', async () => {
|
test('the guard lifts a buried arrival once the surface answers', async () => {
|
||||||
@@ -104,7 +116,11 @@ test('the guard waits for streaming tiles and yields to a newer arrival', async
|
|||||||
|
|
||||||
test('the guard leaves a clear view untouched', async () => {
|
test('the guard leaves a clear view untouched', async () => {
|
||||||
const viewer = fakeViewer({ cameraHeightM: 400, surfaceM: 171 });
|
const viewer = fakeViewer({ cameraHeightM: 400, surfaceM: 171 });
|
||||||
guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 1 });
|
guardCameraAboveGround(
|
||||||
|
viewer,
|
||||||
|
{ lat: 30.3125, lon: -97.765 },
|
||||||
|
{ intervalMs: 1 },
|
||||||
|
);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
assert.equal(viewer.flights.length, 0);
|
assert.equal(viewer.flights.length, 0);
|
||||||
});
|
});
|
||||||
@@ -122,33 +138,76 @@ function handoffFixture(t) {
|
|||||||
viewer.trackedEntityChanged = new Cesium.Event();
|
viewer.trackedEntityChanged = new Cesium.Event();
|
||||||
viewer.camera.cancelFlight = () => {};
|
viewer.camera.cancelFlight = () => {};
|
||||||
viewer.camera.lookAtTransform = () => {};
|
viewer.camera.lookAtTransform = () => {};
|
||||||
viewer.camera.flyToBoundingSphere = () => {};
|
viewer.camera.flyToBoundingSphere = function () {
|
||||||
viewer.camera.setView = () => {};
|
this._currentFlight = {};
|
||||||
|
};
|
||||||
|
viewer.camera.setView = function () {
|
||||||
|
this.positionWC = Cesium.Cartesian3.add(
|
||||||
|
this.positionWC,
|
||||||
|
new Cesium.Cartesian3(0, 0, 50),
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
);
|
||||||
|
};
|
||||||
const originalFly = viewer.camera.flyTo;
|
const originalFly = viewer.camera.flyTo;
|
||||||
const navigation = new NavigationController({
|
const navigation = new NavigationController({
|
||||||
viewer,
|
viewer,
|
||||||
tracking: Object.fromEntries(['flightsLayer', 'militaryFlightsLayer',
|
tracking: Object.fromEntries(
|
||||||
'satellitesLayer', 'aisLiveVesselsLayer', 'militaryAwarenessLayer',
|
[
|
||||||
'rocketLaunchesLayer'].map((key) => [key, {}])),
|
'flightsLayer',
|
||||||
cancelOrientation() {}, clearLocation() {}, cancelShareSelection() {},
|
'militaryFlightsLayer',
|
||||||
getDataManager() {}, interruptCameraMotion() {}, stopOrbit() {},
|
'satellitesLayer',
|
||||||
isCockpitActive: () => false, showToast() {},
|
'aisLiveVesselsLayer',
|
||||||
|
'militaryAwarenessLayer',
|
||||||
|
'rocketLaunchesLayer',
|
||||||
|
].map((key) => [key, {}]),
|
||||||
|
),
|
||||||
|
cancelOrientation() {},
|
||||||
|
clearLocation() {},
|
||||||
|
cancelShareSelection() {},
|
||||||
|
getDataManager() {},
|
||||||
|
interruptCameraMotion() {},
|
||||||
|
stopOrbit() {},
|
||||||
|
isCockpitActive: () => false,
|
||||||
|
showToast() {},
|
||||||
});
|
});
|
||||||
guardCameraAboveGround(viewer, { lat: 30.3, lon: -97.7 });
|
guardCameraAboveGround(viewer, { lat: 30.3, lon: -97.7 });
|
||||||
return { viewer, navigation, originalFly };
|
return { viewer, navigation, originalFly };
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
|
for (const handoff of [
|
||||||
'flyTo', 'flyToBoundingSphere', 'setView', 'layer-disable', 'layer-destroy', 'app-stop']) {
|
'Director',
|
||||||
|
'tracking',
|
||||||
|
'follow',
|
||||||
|
'keyboard',
|
||||||
|
'UI',
|
||||||
|
'flyTo',
|
||||||
|
'flyToBoundingSphere',
|
||||||
|
'setView',
|
||||||
|
'layer-disable',
|
||||||
|
'layer-destroy',
|
||||||
|
'app-stop',
|
||||||
|
]) {
|
||||||
test(`arrival guard relinquishes and removes hooks on ${handoff}`, async (t) => {
|
test(`arrival guard relinquishes and removes hooks on ${handoff}`, async (t) => {
|
||||||
const { viewer, navigation, originalFly } = handoffFixture(t);
|
const { viewer, navigation, originalFly } = handoffFixture(t);
|
||||||
|
const methods = [
|
||||||
|
'flyTo',
|
||||||
|
'flyToBoundingSphere',
|
||||||
|
'setView',
|
||||||
|
'lookAt',
|
||||||
|
'cancelFlight',
|
||||||
|
].map((name) => viewer.camera[name]);
|
||||||
if (handoff === 'Director') {
|
if (handoff === 'Director') {
|
||||||
SceneDirector.prototype._claimCameraOwnership.call({
|
SceneDirector.prototype._claimCameraOwnership.call({
|
||||||
viewer, styleManager: { runImmediateNavigation: (noun, fn) =>
|
viewer,
|
||||||
navigation._runExplicitNavigation(noun, fn) },
|
styleManager: {
|
||||||
|
runImmediateNavigation: (noun, fn) =>
|
||||||
|
navigation._runExplicitNavigation(noun, fn),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} else if (handoff === 'tracking') {
|
} else if (handoff === 'tracking') {
|
||||||
viewer.trackedEntityChanged.raiseEvent({ id: 'contact' });
|
// As Cesium's setter does: the value changes, then the event fires.
|
||||||
|
viewer.trackedEntity = { id: 'contact' };
|
||||||
|
viewer.trackedEntityChanged.raiseEvent(viewer.trackedEntity);
|
||||||
} else if (handoff === 'follow' || handoff === 'UI') {
|
} else if (handoff === 'follow' || handoff === 'UI') {
|
||||||
navigation._stampNavigation({ cancelPendingSelection: false });
|
navigation._stampNavigation({ cancelPendingSelection: false });
|
||||||
} else if (handoff === 'keyboard') {
|
} else if (handoff === 'keyboard') {
|
||||||
@@ -156,16 +215,81 @@ for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
|
|||||||
} else if (handoff === 'app-stop') navigation.stop();
|
} else if (handoff === 'app-stop') navigation.stop();
|
||||||
else if (handoff.startsWith('layer-')) {
|
else if (handoff.startsWith('layer-')) {
|
||||||
const lifecycle = new LayerLifecycle(viewer);
|
const lifecycle = new LayerLifecycle(viewer);
|
||||||
lifecycle.register({ id: 'test', init() {}, enable() {}, disable() {}, destroy() {} });
|
lifecycle.register({
|
||||||
|
id: 'test',
|
||||||
|
init() {},
|
||||||
|
enable() {},
|
||||||
|
disable() {},
|
||||||
|
destroy() {},
|
||||||
|
});
|
||||||
await lifecycle.setEnabled('test', true);
|
await lifecycle.setEnabled('test', true);
|
||||||
if (handoff === 'layer-disable') await lifecycle.setEnabled('test', false);
|
if (handoff === 'layer-disable')
|
||||||
|
await lifecycle.setEnabled('test', false);
|
||||||
else await lifecycle.destroyLayer('test');
|
else await lifecycle.destroyLayer('test');
|
||||||
} else viewer.camera[handoff]({});
|
} else viewer.camera[handoff]({});
|
||||||
|
// A direct flyTo is itself one recorded flight; the guard adds none.
|
||||||
const priorFlights = viewer.flights.length;
|
const priorFlights = viewer.flights.length;
|
||||||
t.mock.timers.tick(10000);
|
t.mock.timers.tick(10000);
|
||||||
assert.equal(viewer.flights.length, priorFlights, 'the old probe cannot fly');
|
assert.equal(
|
||||||
assert.equal(viewer.camera.flyTo, originalFly, 'direct-call hook is removed');
|
viewer.flights.length,
|
||||||
|
priorFlights,
|
||||||
|
'the old probe cannot fly',
|
||||||
|
);
|
||||||
|
assert.equal(viewer.camera.flyTo, originalFly);
|
||||||
|
assert.deepEqual(
|
||||||
|
['flyTo', 'flyToBoundingSphere', 'setView', 'lookAt', 'cancelFlight'].map(
|
||||||
|
(name) => viewer.camera[name],
|
||||||
|
),
|
||||||
|
methods,
|
||||||
|
'the guard never replaces camera methods',
|
||||||
|
);
|
||||||
assert.equal(viewer.trackedEntityChanged.numberOfListeners, 0);
|
assert.equal(viewer.trackedEntityChanged.numberOfListeners, 0);
|
||||||
navigation.destroy();
|
navigation.destroy();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('the guard yields to a camera moved without any camera method', async () => {
|
||||||
|
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
|
||||||
|
guardCameraAboveGround(
|
||||||
|
viewer,
|
||||||
|
{ lat: 30.3125, lon: -97.765 },
|
||||||
|
{ intervalMs: 5 },
|
||||||
|
);
|
||||||
|
// A cockpit or follow frame writes the pose directly.
|
||||||
|
viewer.camera.directionWC = new Cesium.Cartesian3(1, 0, 0);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
assert.equal(viewer.flights.length, 0, 'the moved view is not corrected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Cesium lifting a buried eye straight up is not another owner', async () => {
|
||||||
|
const viewer = fakeViewer({ cameraHeightM: 104, surfaceM: 171 });
|
||||||
|
guardCameraAboveGround(
|
||||||
|
viewer,
|
||||||
|
{ lat: 30.3125, lon: -97.765 },
|
||||||
|
{ intervalMs: 5 },
|
||||||
|
);
|
||||||
|
// Terrain collision raises the eye to just above the loaded ground.
|
||||||
|
const raised = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, 172);
|
||||||
|
viewer.camera.positionCartographic = raised;
|
||||||
|
viewer.camera.positionWC = Cesium.Cartographic.toCartesian(raised);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
assert.equal(viewer.flights.length, 1, 'the guard still lifts the eye');
|
||||||
|
const lifted = Cesium.Cartographic.fromCartesian(
|
||||||
|
viewer.flights[0].destination,
|
||||||
|
);
|
||||||
|
assert.ok(Math.abs(lifted.height - (171 + MIN_EYE_CLEARANCE_M)) < 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('another owner lowering the eye is not corrected', async () => {
|
||||||
|
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
|
||||||
|
guardCameraAboveGround(
|
||||||
|
viewer,
|
||||||
|
{ lat: 30.3125, lon: -97.765 },
|
||||||
|
{ intervalMs: 5 },
|
||||||
|
);
|
||||||
|
const lowered = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, 150);
|
||||||
|
viewer.camera.positionCartographic = lowered;
|
||||||
|
viewer.camera.positionWC = Cesium.Cartographic.toCartesian(lowered);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
assert.equal(viewer.flights.length, 0);
|
||||||
|
});
|
||||||
@@ -44,8 +44,8 @@ test('Cockpit takeover invalidates deferred work before camera cancellation', ()
|
|||||||
ordered(enter, [
|
ordered(enter, [
|
||||||
'if (!info || !entity?.position) return false;',
|
'if (!info || !entity?.position) return false;',
|
||||||
'this.onCameraTakeover?.();',
|
'this.onCameraTakeover?.();',
|
||||||
'this.viewer.camera.cancelFlight();',
|
'cameraOf(this.viewer).cancelFlight();',
|
||||||
'this.viewer.trackedEntity = undefined;',
|
'stopFollowing(this.viewer);',
|
||||||
], 'Cockpit takeover');
|
], 'Cockpit takeover');
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
ui,
|
||||||
@@ -97,7 +97,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
|
|||||||
// The transaction has exactly two camera-owner mutations, and each one
|
// The transaction has exactly two camera-owner mutations, and each one
|
||||||
// stamps:
|
// stamps:
|
||||||
// 1. selectedLayer.trackById(id) -> viewer.trackedEntity
|
// 1. selectedLayer.trackById(id) -> viewer.trackedEntity
|
||||||
// -> viewer.trackedEntityChanged -> _stampNavigation()
|
// -> onFollowChanged(viewer) -> _stampNavigation()
|
||||||
// 2. cockpitView.enter() -> onCameraTakeover() -> _stampNavigation()
|
// 2. cockpitView.enter() -> onCameraTakeover() -> _stampNavigation()
|
||||||
const transaction = body(
|
const transaction = body(
|
||||||
cockpitTracking,
|
cockpitTracking,
|
||||||
@@ -117,7 +117,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
|
|||||||
// Seam 1: any tracker handoff stamps, so the adoption step is covered.
|
// Seam 1: any tracker handoff stamps, so the adoption step is covered.
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
ui,
|
||||||
/viewer\.trackedEntityChanged\.addEventListener\(\s*\(entity\) => \{\s*if \(entity && !this\._disposed\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/,
|
/onFollowChanged\(\s*this\.viewer,\s*\(followed\) => \{\s*if \(followed && !this\._disposed\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/,
|
||||||
'tracker handoff must stamp',
|
'tracker handoff must stamp',
|
||||||
);
|
);
|
||||||
// Seam 2 is pinned by "Cockpit takeover invalidates deferred work" above.
|
// Seam 2 is pinned by "Cockpit takeover invalidates deferred work" above.
|
||||||
@@ -190,10 +190,10 @@ test('accepted navigation releases through PR15-aware ownership before flight',
|
|||||||
'origin: trackingOrigin',
|
'origin: trackingOrigin',
|
||||||
'satellitesLayer.stopTracking?.({ origin: trackingOrigin })',
|
'satellitesLayer.stopTracking?.({ origin: trackingOrigin })',
|
||||||
'rocketLaunchesLayer.releaseCameraOwnership?.()',
|
'rocketLaunchesLayer.releaseCameraOwnership?.()',
|
||||||
'this.viewer.trackedEntity = undefined;',
|
'stopFollowing(this.viewer);',
|
||||||
"interruptCameraMotion('explicit-navigation')",
|
"interruptCameraMotion('explicit-navigation')",
|
||||||
'this.viewer.camera.cancelFlight();',
|
'camera?.cancelFlight();',
|
||||||
'this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);',
|
'camera?.release();',
|
||||||
], 'follow release');
|
], 'follow release');
|
||||||
assert.match(release, /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
|
assert.match(release, /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
|
||||||
assert.match(release, /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
|
assert.match(release, /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
|
||||||
@@ -389,7 +389,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
|
|||||||
}
|
}
|
||||||
const vesselClick = body(
|
const vesselClick = body(
|
||||||
vessels,
|
vessels,
|
||||||
/handler\.setInputAction\(\s*\(click\) => \{([\s\S]*?)\n\s*\},\s*Cesium\.ScreenSpaceEventType\.LEFT_CLICK,?\s*\);/,
|
/inputFor\(viewer\)\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/,
|
||||||
'vessel click',
|
'vessel click',
|
||||||
);
|
);
|
||||||
ordered(vesselClick, [
|
ordered(vesselClick, [
|
||||||
@@ -405,7 +405,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
|
|||||||
assert.match(vesselFocus, /requestWorldFocus\(\{/);
|
assert.match(vesselFocus, /requestWorldFocus\(\{/);
|
||||||
const fireClick = body(
|
const fireClick = body(
|
||||||
firms,
|
firms,
|
||||||
/_clickHandler\.setInputAction\(\(click\) => \{([\s\S]*?)\n \}, Cesium\.ScreenSpaceEventType\.LEFT_CLICK\);/,
|
/input\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/,
|
||||||
'fire click',
|
'fire click',
|
||||||
);
|
);
|
||||||
ordered(fireClick, [
|
ordered(fireClick, [
|
||||||
|
|||||||
+42
-46
@@ -18,6 +18,7 @@
|
|||||||
* globe motion, opposite to the camera's travel around Earth.
|
* globe motion, opposite to the camera's travel around Earth.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { followedOf, isFollowing, onFollowChanged } from './data/followed.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { GLOBE_VIEW } from './locations.js';
|
import { GLOBE_VIEW } from './locations.js';
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +30,9 @@ import {
|
|||||||
holdContinuousRender,
|
holdContinuousRender,
|
||||||
releaseContinuousRender,
|
releaseContinuousRender,
|
||||||
} from './renderGovernor.js';
|
} from './renderGovernor.js';
|
||||||
|
import { heightsFor } from './queries/heights.js';
|
||||||
|
import { cameraOf } from './render/cesium/camera.js';
|
||||||
|
import { findCamera } from './render/rendererOf.js';
|
||||||
|
|
||||||
let _isCockpitActive = null;
|
let _isCockpitActive = null;
|
||||||
/** °/s by speed word — orbit; pan uses fractions of view height/s. */
|
/** °/s by speed word — orbit; pan uses fractions of view height/s. */
|
||||||
@@ -559,25 +563,18 @@ function acquireCorridorFloor(state, takeWhole) {
|
|||||||
export function probeMeshFloorM(scene, cells) {
|
export function probeMeshFloorM(scene, cells) {
|
||||||
const requested = Array.isArray(cells) ? cells.length : 0;
|
const requested = Array.isArray(cells) ? cells.length : 0;
|
||||||
const empty = { heightM: Number.NaN, sampled: 0, requested };
|
const empty = { heightM: Number.NaN, sampled: 0, requested };
|
||||||
if (!scene || typeof scene.sampleHeight !== 'function' || !requested)
|
if (!scene || !requested) return empty;
|
||||||
return empty;
|
const heights = heightsFor(scene);
|
||||||
let heightM = Number.NaN;
|
let heightM = Number.NaN;
|
||||||
let sampled = 0;
|
let sampled = 0;
|
||||||
for (const cell of cells) {
|
for (const cell of cells) {
|
||||||
try {
|
// A miss is a cell whose tiles are not ready.
|
||||||
const carto = Cesium.Cartographic.fromDegrees(
|
const height = heights.now(
|
||||||
cell.lon,
|
Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, _probeCarto),
|
||||||
cell.lat,
|
);
|
||||||
0,
|
if (height === null) continue;
|
||||||
_probeCarto,
|
sampled += 1;
|
||||||
);
|
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
|
||||||
const height = scene.sampleHeight(carto);
|
|
||||||
if (!Number.isFinite(height)) continue;
|
|
||||||
sampled += 1;
|
|
||||||
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
|
|
||||||
} catch {
|
|
||||||
/* tiles not ready for this cell */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { heightM, sampled, requested };
|
return { heightM, sampled, requested };
|
||||||
}
|
}
|
||||||
@@ -1010,7 +1007,7 @@ export function interruptCameraMotionIfActive(
|
|||||||
|
|
||||||
function clearLookAt() {
|
function clearLookAt() {
|
||||||
try {
|
try {
|
||||||
_viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
findCamera(_viewer)?.release();
|
||||||
} catch {
|
} catch {
|
||||||
/* teardown race */
|
/* teardown race */
|
||||||
}
|
}
|
||||||
@@ -1028,13 +1025,11 @@ function clearLookAt() {
|
|||||||
*/
|
*/
|
||||||
function levelCameraRoll() {
|
function levelCameraRoll() {
|
||||||
try {
|
try {
|
||||||
const cam = _viewer?.camera;
|
const camera = findCamera(_viewer);
|
||||||
if (!cam || typeof cam.setView !== 'function') return false;
|
if (!camera) return false;
|
||||||
const roll = Cesium.Math.negativePiToPi(cam.roll || 0);
|
const { heading, pitch, roll } = camera.pose();
|
||||||
if (Math.abs(roll) < 1e-5) return false;
|
if (Math.abs(Cesium.Math.negativePiToPi(roll || 0)) < 1e-5) return false;
|
||||||
cam.setView({
|
camera.setView({ orientation: { heading, pitch, roll: 0 } });
|
||||||
orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false; // teardown race
|
return false; // teardown race
|
||||||
@@ -1047,7 +1042,7 @@ export function interruptCameraMotion(reason = 'interrupt') {
|
|||||||
// While an entity is TRACKED, the follow camera owns the reference frame —
|
// While an entity is TRACKED, the follow camera owns the reference frame —
|
||||||
// resetting the lookAt transform here flings the camera into space (field
|
// resetting the lookAt transform here flings the camera into space (field
|
||||||
// finding). Leave the transform to the tracker; it re-asserts every frame.
|
// finding). Leave the transform to the tracker; it re-asserts every frame.
|
||||||
const tracking = Boolean(_viewer?.trackedEntity);
|
const tracking = isFollowing(_viewer);
|
||||||
const wasRoute = wasActive && _active.kind === 'route';
|
const wasRoute = wasActive && _active.kind === 'route';
|
||||||
if (
|
if (
|
||||||
wasActive &&
|
wasActive &&
|
||||||
@@ -1087,6 +1082,9 @@ export function getActiveCameraMotion() {
|
|||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A pan's direction as the camera move it makes; any other is down. */
|
||||||
|
const PAN_MOVES = Object.freeze({ left: 'left', right: 'right', up: 'up' });
|
||||||
|
|
||||||
function onTick() {
|
function onTick() {
|
||||||
if (!_active || !_viewer) return;
|
if (!_active || !_viewer) return;
|
||||||
// Wall-clock dt: clock.currentTime FREEZES when the app clock isn't
|
// Wall-clock dt: clock.currentTime FREEZES when the app clock isn't
|
||||||
@@ -1097,6 +1095,8 @@ function onTick() {
|
|||||||
Math.max(0.001, (nowMs - (_active.lastMs ?? nowMs)) / 1000),
|
Math.max(0.001, (nowMs - (_active.lastMs ?? nowMs)) / 1000),
|
||||||
);
|
);
|
||||||
_active.lastMs = nowMs;
|
_active.lastMs = nowMs;
|
||||||
|
// Reads stay on Cesium's camera until the query port has them; every
|
||||||
|
// write goes through the camera port.
|
||||||
const cam = _viewer.camera;
|
const cam = _viewer.camera;
|
||||||
const m = _active;
|
const m = _active;
|
||||||
|
|
||||||
@@ -1112,6 +1112,7 @@ function onTick() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const camera = cameraOf(_viewer);
|
||||||
if (m.kind === 'orbit' && m.pending) {
|
if (m.kind === 'orbit' && m.pending) {
|
||||||
// Armed but target-less (started mid-flight, e.g. "fly to X and orbit
|
// Armed but target-less (started mid-flight, e.g. "fly to X and orbit
|
||||||
// it"): keep trying for a ground target until the camera settles.
|
// it"): keep trying for a ground target until the camera settles.
|
||||||
@@ -1161,7 +1162,7 @@ function onTick() {
|
|||||||
} else {
|
} else {
|
||||||
m.hpr.heading += rate * dt;
|
m.hpr.heading += rate * dt;
|
||||||
}
|
}
|
||||||
cam.lookAt(m.target, m.hpr);
|
camera.lookAt(m.target, m.hpr);
|
||||||
} else if (m.kind === 'pan') {
|
} else if (m.kind === 'pan') {
|
||||||
const heightM = Math.max(50, cam.positionCartographic.height);
|
const heightM = Math.max(50, cam.positionCartographic.height);
|
||||||
const step =
|
const step =
|
||||||
@@ -1169,10 +1170,7 @@ function onTick() {
|
|||||||
? heightM * ONCE.panViewFraction * (scale - (m.lastScale || 0))
|
? heightM * ONCE.panViewFraction * (scale - (m.lastScale || 0))
|
||||||
: heightM * PAN_VIEW_FRACTION_S[m.speed] * dt;
|
: heightM * PAN_VIEW_FRACTION_S[m.speed] * dt;
|
||||||
m.lastScale = scale;
|
m.lastScale = scale;
|
||||||
if (m.direction === 'left') cam.moveLeft(step);
|
camera.move(PAN_MOVES[m.direction] ?? 'down', step);
|
||||||
else if (m.direction === 'right') cam.moveRight(step);
|
|
||||||
else if (m.direction === 'up') cam.moveUp(step);
|
|
||||||
else cam.moveDown(step);
|
|
||||||
} else if (
|
} else if (
|
||||||
m.kind === 'tilt' ||
|
m.kind === 'tilt' ||
|
||||||
m.kind === 'rotate' ||
|
m.kind === 'rotate' ||
|
||||||
@@ -1193,7 +1191,7 @@ function onTick() {
|
|||||||
// only describes Cesium's rotation while the frame is identity.
|
// only describes Cesium's rotation while the frame is identity.
|
||||||
if (
|
if (
|
||||||
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
|
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
|
||||||
_viewer.trackedEntity ||
|
isFollowing(_viewer) ||
|
||||||
_isCockpitActive?.() ||
|
_isCockpitActive?.() ||
|
||||||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
|
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
|
||||||
) {
|
) {
|
||||||
@@ -1217,7 +1215,7 @@ function onTick() {
|
|||||||
interruptCameraMotion('globe-surface-limit');
|
interruptCameraMotion('globe-surface-limit');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cam.rotate(m.rotationAxis, angle);
|
camera.rotateAround(m.rotationAxis, angle);
|
||||||
} else if (m.kind === 'tilt') {
|
} else if (m.kind === 'tilt') {
|
||||||
const up = m.direction === 'up';
|
const up = m.direction === 'up';
|
||||||
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
|
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
|
||||||
@@ -1226,10 +1224,8 @@ function onTick() {
|
|||||||
interruptCameraMotion('tilt-clamp');
|
interruptCameraMotion('tilt-clamp');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (up) cam.lookUp(stepRad);
|
camera.look(up ? 'up' : 'down', stepRad);
|
||||||
else cam.lookDown(stepRad);
|
} else camera.look(m.direction === 'left' ? 'left' : 'right', stepRad);
|
||||||
} else if (m.direction === 'left') cam.lookLeft(stepRad);
|
|
||||||
else cam.lookRight(stepRad);
|
|
||||||
} else if (m.kind === 'route') {
|
} else if (m.kind === 'route') {
|
||||||
// The dolly's shaping lives in advanceRouteFlight(); the tick only
|
// The dolly's shaping lives in advanceRouteFlight(); the tick only
|
||||||
// applies the pose and honours the same completion path as before.
|
// applies the pose and honours the same completion path as before.
|
||||||
@@ -1238,7 +1234,7 @@ function onTick() {
|
|||||||
// user left it rather than teleporting to an altitude derived from
|
// user left it rather than teleporting to an altitude derived from
|
||||||
// nothing. An interrupt during the arm is still instant.
|
// nothing. An interrupt during the arm is still instant.
|
||||||
if (frame.arming) return;
|
if (frame.arming) return;
|
||||||
cam.setView({
|
camera.setView({
|
||||||
destination: frame.eye,
|
destination: frame.eye,
|
||||||
orientation: { direction: frame.direction, up: frame.up },
|
orientation: { direction: frame.direction, up: frame.up },
|
||||||
});
|
});
|
||||||
@@ -1418,8 +1414,8 @@ export function initCameraVerbs(
|
|||||||
if (_trackingRemover) _trackingRemover();
|
if (_trackingRemover) _trackingRemover();
|
||||||
// Cockpit can acquire then clear tracking before the next clock tick.
|
// Cockpit can acquire then clear tracking before the next clock tick.
|
||||||
// Yield on acquisition itself without changing legacy camera motions.
|
// Yield on acquisition itself without changing legacy camera motions.
|
||||||
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
|
_trackingRemover = onFollowChanged(viewer, (followed) => {
|
||||||
if (entity && _active?.kind === 'rotate_globe')
|
if (followed && _active?.kind === 'rotate_globe')
|
||||||
interruptCameraMotion('globe-camera-owner-changed');
|
interruptCameraMotion('globe-camera-owner-changed');
|
||||||
});
|
});
|
||||||
for (const rm of _inputRemovers) rm();
|
for (const rm of _inputRemovers) rm();
|
||||||
@@ -1527,15 +1523,14 @@ export function moveCamera(args = {}, runNavigation = null) {
|
|||||||
if (
|
if (
|
||||||
(motion === 'rotate_globe' ||
|
(motion === 'rotate_globe' ||
|
||||||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
|
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
|
||||||
_viewer?.trackedEntity
|
isFollowing(_viewer)
|
||||||
) {
|
) {
|
||||||
// The follow camera owns a tracked view; a lookAt orbit fights it frame
|
// The follow camera owns a tracked view; a lookAt orbit fights it frame
|
||||||
// by frame and stop rips the camera out (field finding). Honest refusal
|
// by frame and stop rips the camera out (field finding). Honest refusal
|
||||||
// until tracked-orbit rides the follow camera natively (roadmap).
|
// until tracked-orbit rides the follow camera natively (roadmap).
|
||||||
|
const followed = followedOf(_viewer);
|
||||||
const label =
|
const label =
|
||||||
_viewer.trackedEntity?.name ||
|
followed?.name || followed?.labelModel?.title || 'the tracked target';
|
||||||
_viewer.trackedEntity?.id ||
|
|
||||||
'the tracked target';
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
action: 'move_camera',
|
action: 'move_camera',
|
||||||
@@ -1618,9 +1613,10 @@ export function moveCamera(args = {}, runNavigation = null) {
|
|||||||
if (motion === 'rotate_globe') {
|
if (motion === 'rotate_globe') {
|
||||||
// Convert an inherited ground-target frame without changing the world
|
// Convert an inherited ground-target frame without changing the world
|
||||||
// pose. Rotation must pivot about Earth, never the previous local target.
|
// pose. Rotation must pivot about Earth, never the previous local target.
|
||||||
|
const camera = cameraOf(_viewer);
|
||||||
|
camera.cancelFlight();
|
||||||
|
camera.release();
|
||||||
const cam = _viewer.camera;
|
const cam = _viewer.camera;
|
||||||
cam.cancelFlight();
|
|
||||||
cam.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
|
||||||
// Handoff may level a route's bank. Keep the caller's exact world pose.
|
// Handoff may level a route's bank. Keep the caller's exact world pose.
|
||||||
// Copy the basis directly: setView's heading/pitch conversion is singular
|
// Copy the basis directly: setView's heading/pitch conversion is singular
|
||||||
// at nadir and can change the captured orientation near the poles.
|
// at nadir and can change the captured orientation near the poles.
|
||||||
@@ -1705,7 +1701,7 @@ export function moveCamera(args = {}, runNavigation = null) {
|
|||||||
const preserveCameraFlight =
|
const preserveCameraFlight =
|
||||||
motion === 'orbit' &&
|
motion === 'orbit' &&
|
||||||
_viewer.scene.tweens.length > 0 &&
|
_viewer.scene.tweens.length > 0 &&
|
||||||
!_viewer.trackedEntity;
|
!isFollowing(_viewer);
|
||||||
return typeof runNavigation === 'function'
|
return typeof runNavigation === 'function'
|
||||||
? runNavigation(start, { preserveCameraFlight })
|
? runNavigation(start, { preserveCameraFlight })
|
||||||
: start();
|
: start();
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Activates the nearest CCTV record on layer enable and flies only when no
|
* Activates the nearest CCTV record on layer enable and flies only when no
|
||||||
* aircraft tracker or cockpit camera owns the view.
|
* followed target or cockpit camera owns the view.
|
||||||
* @param {Object} options Layer-enable focus inputs.
|
* @param {Object} options Layer-enable focus inputs.
|
||||||
* @param {*} options.trackedEntity Current Cesium tracked entity, if any.
|
* @param {*} options.followed What the camera follows, if anything
|
||||||
|
* (`followedOf`).
|
||||||
* @param {boolean} options.cockpitActive Whether cockpit mode owns the camera.
|
* @param {boolean} options.cockpitActive Whether cockpit mode owns the camera.
|
||||||
* @param {() => (string|null)} options.activate Nearest-camera activation.
|
* @param {() => (string|null)} options.activate Nearest-camera activation.
|
||||||
* @param {(cameraId: string) => *} options.fly Optional camera flight.
|
* @param {(cameraId: string) => *} options.fly Optional camera flight.
|
||||||
* @returns {*} Activated camera ID for a held view, or the flight result.
|
* @returns {*} Activated camera ID for a held view, or the flight result.
|
||||||
*/
|
*/
|
||||||
export function runCctvLayerEnableFocus({
|
export function runCctvLayerEnableFocus({
|
||||||
trackedEntity = null,
|
followed = null,
|
||||||
cockpitActive = false,
|
cockpitActive = false,
|
||||||
activate,
|
activate,
|
||||||
fly,
|
fly,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const cameraId = activate?.();
|
const cameraId = activate?.();
|
||||||
if (!cameraId) return false;
|
if (!cameraId) return false;
|
||||||
if (trackedEntity || cockpitActive) return cameraId;
|
if (followed || cockpitActive) return cameraId;
|
||||||
return fly?.(cameraId);
|
return fly?.(cameraId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,11 +26,11 @@ export function runCctvLayerEnableFocus({
|
|||||||
* chain. Either observation suppresses the optional CCTV focus flight.
|
* chain. Either observation suppresses the optional CCTV focus flight.
|
||||||
* @param {Object} [before={}] Ownership immediately before the await.
|
* @param {Object} [before={}] Ownership immediately before the await.
|
||||||
* @param {Object} [after={}] Ownership immediately after the await.
|
* @param {Object} [after={}] Ownership immediately after the await.
|
||||||
* @returns {{ trackedEntity: *, cockpitActive: boolean }} Conservative ownership.
|
* @returns {{ followed: *, cockpitActive: boolean }} Conservative ownership.
|
||||||
*/
|
*/
|
||||||
function mergeCctvEnableOwnership(before = {}, after = {}) {
|
function mergeCctvEnableOwnership(before = {}, after = {}) {
|
||||||
return {
|
return {
|
||||||
trackedEntity: before.trackedEntity || after.trackedEntity || null,
|
followed: before.followed || after.followed || null,
|
||||||
cockpitActive: !!(before.cockpitActive || after.cockpitActive),
|
cockpitActive: !!(before.cockpitActive || after.cockpitActive),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -60,7 +61,7 @@ export async function runCctvLayerEnableTransition({
|
|||||||
const ownershipBefore = readOwnership?.() || {};
|
const ownershipBefore = readOwnership?.() || {};
|
||||||
if (target === true) {
|
if (target === true) {
|
||||||
debug?.('[UI:CCTV] ownership before setEnabled await', {
|
debug?.('[UI:CCTV] ownership before setEnabled await', {
|
||||||
trackedId: ownershipBefore.trackedEntity?.id ?? null,
|
trackedId: ownershipBefore.followed?.key ?? null,
|
||||||
cockpitActive: !!ownershipBefore.cockpitActive,
|
cockpitActive: !!ownershipBefore.cockpitActive,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -68,7 +69,7 @@ export async function runCctvLayerEnableTransition({
|
|||||||
const ownershipAfter = readOwnership?.() || {};
|
const ownershipAfter = readOwnership?.() || {};
|
||||||
if (target === true) {
|
if (target === true) {
|
||||||
debug?.('[UI:CCTV] ownership after setEnabled await', {
|
debug?.('[UI:CCTV] ownership after setEnabled await', {
|
||||||
trackedId: ownershipAfter.trackedEntity?.id ?? null,
|
trackedId: ownershipAfter.followed?.key ?? null,
|
||||||
cockpitActive: !!ownershipAfter.cockpitActive,
|
cockpitActive: !!ownershipAfter.cockpitActive,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
|
|
||||||
test('CCTV layer enable activates nearest without stealing a tracked or cockpit view', () => {
|
test('CCTV layer enable activates nearest without stealing a tracked or cockpit view', () => {
|
||||||
for (const ownership of [
|
for (const ownership of [
|
||||||
{ trackedEntity: { id: 'tracked-plane' }, cockpitActive: false },
|
{ followed: { key: 'tracked-plane' }, cockpitActive: false },
|
||||||
{ trackedEntity: null, cockpitActive: true },
|
{ followed: null, cockpitActive: true },
|
||||||
]) {
|
]) {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const result = runCctvLayerEnableFocus({
|
const result = runCctvLayerEnableFocus({
|
||||||
@@ -48,15 +48,15 @@ test('CCTV layer enable activates nearest without stealing a tracked or cockpit
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('CCTV enable retains a pre-await tracking snapshot when tracking clears during enable', async () => {
|
test('CCTV enable retains a pre-await tracking snapshot when tracking clears during enable', async () => {
|
||||||
let trackedEntity = { id: 'tracked-plane' };
|
let followed = { key: 'tracked-plane' };
|
||||||
let flyCalls = 0;
|
let flyCalls = 0;
|
||||||
const diagnostics = [];
|
const diagnostics = [];
|
||||||
const result = await runCctvLayerEnableTransition({
|
const result = await runCctvLayerEnableTransition({
|
||||||
target: true,
|
target: true,
|
||||||
readOwnership: () => ({ trackedEntity, cockpitActive: false }),
|
readOwnership: () => ({ followed, cockpitActive: false }),
|
||||||
setEnabled: async () => {
|
setEnabled: async () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
trackedEntity = null;
|
followed = null;
|
||||||
},
|
},
|
||||||
shouldFocus: () => true,
|
shouldFocus: () => true,
|
||||||
activate: () => 'cam-near',
|
activate: () => 'cam-near',
|
||||||
@@ -83,7 +83,7 @@ test('CCTV disable transition does not emit enable-ownership diagnostics', async
|
|||||||
|
|
||||||
const result = await runCctvLayerEnableTransition({
|
const result = await runCctvLayerEnableTransition({
|
||||||
target: false,
|
target: false,
|
||||||
readOwnership: () => ({ trackedEntity: { id: 'tracked-plane' }, cockpitActive: false }),
|
readOwnership: () => ({ followed: { key: 'tracked-plane' }, cockpitActive: false }),
|
||||||
setEnabled: async (target) => transitions.push(target),
|
setEnabled: async (target) => transitions.push(target),
|
||||||
shouldFocus: () => true,
|
shouldFocus: () => true,
|
||||||
activate: () => 'cam-near',
|
activate: () => 'cam-near',
|
||||||
|
|||||||
+18
-14
@@ -1,5 +1,6 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { governorRequestRender } from './renderGovernor.js';
|
import { governorRequestRender } from './renderGovernor.js';
|
||||||
|
import { cameraOf } from './render/cesium/camera.js';
|
||||||
|
|
||||||
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
|
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
|
||||||
export const KEYHOLE_OUTER_RADIUS = 1.05;
|
export const KEYHOLE_OUTER_RADIUS = 1.05;
|
||||||
@@ -576,30 +577,33 @@ export class CelestialRing {
|
|||||||
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
|
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
|
||||||
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
|
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
|
||||||
|
|
||||||
|
const camera = cameraOf(this.viewer);
|
||||||
this._focusInProgress = true;
|
this._focusInProgress = true;
|
||||||
const finishFocus = () => {
|
const finishFocus = () => {
|
||||||
this._focusInProgress = false;
|
this._focusInProgress = false;
|
||||||
this.viewer.scene.requestRender?.();
|
this.viewer.scene.requestRender?.();
|
||||||
};
|
};
|
||||||
this.viewer.camera.flyTo({
|
camera.flyTo(
|
||||||
destination: Cesium.Cartesian3.fromRadians(
|
Cesium.Cartesian3.fromRadians(
|
||||||
cartographic.longitude,
|
cartographic.longitude,
|
||||||
cartographic.latitude,
|
cartographic.latitude,
|
||||||
altitude,
|
altitude,
|
||||||
),
|
),
|
||||||
orientation: {
|
{
|
||||||
heading: this.viewer.camera.heading,
|
orientation: {
|
||||||
pitch: -Cesium.Math.PI_OVER_TWO,
|
heading: camera.pose().heading,
|
||||||
roll: 0,
|
pitch: -Cesium.Math.PI_OVER_TWO,
|
||||||
|
roll: 0,
|
||||||
|
},
|
||||||
|
durationS: duration,
|
||||||
|
easing: 'cubicInOut',
|
||||||
|
onComplete: finishFocus,
|
||||||
|
onCancel: () => {
|
||||||
|
finishFocus();
|
||||||
|
if (!this.isGlobeFullyVisible()) this._autoDisable();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
duration,
|
);
|
||||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
|
||||||
complete: finishFocus,
|
|
||||||
cancel: () => {
|
|
||||||
finishFocus();
|
|
||||||
if (!this.isGlobeFullyVisible()) this._autoDisable();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
|
|||||||
assert.doesNotMatch(exit, /applyTrackedCameraFrame/);
|
assert.doesNotMatch(exit, /applyTrackedCameraFrame/);
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
ui,
|
||||||
/restoreTrackingFrame: \(entity\) => \{[\s\S]*?gevTrackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/,
|
/restoreTrackingFrame: \(entity\) => \{[\s\S]*?readEntityRecord\(entity\)\?\.trackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/,
|
||||||
'Cockpit exit must return camera-frame ownership through the source layer so Contact Focus cannot accumulate a second owner',
|
'Cockpit exit must return camera-frame ownership through the source layer so Contact Focus cannot accumulate a second owner',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -238,7 +238,7 @@ test('Cockpit shortcut failures do not leak and open utilities own the first Esc
|
|||||||
assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/);
|
assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/);
|
||||||
assert.match(
|
assert.match(
|
||||||
keydown,
|
keydown,
|
||||||
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
|
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && followedOf\(this\.viewer\)\?\.entity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -295,14 +295,14 @@ test('the Contact panel never hides itself out from under its own NEXT button',
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the cockpit reads its aircraft from the layer that owns Cesium tracking', () => {
|
test('the cockpit reads its aircraft from the layer the camera follows', () => {
|
||||||
const read = readAircraftInfo.toString();
|
const read = readAircraftInfo.toString();
|
||||||
assert.ok(read, 'readAircraftInfo is missing');
|
assert.ok(read, 'readAircraftInfo is missing');
|
||||||
assert.match(read, /resolveTrackedAircraftInfo\(\{/);
|
assert.match(read, /resolveTrackedAircraftInfo\(\{/);
|
||||||
assert.match(read, /gevTrackedId/);
|
assert.match(read, /trackedId: followed\?\.key/);
|
||||||
// In cockpit mode the controller moves the entity off viewer.trackedEntity,
|
// In cockpit mode the controller stops the camera following, so its own
|
||||||
// so its own handle is the tracked identity there.
|
// handle is the tracked identity there.
|
||||||
assert.match(read, /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/);
|
assert.match(read, /followedOf\(this\.viewer\) \|\| this\.trackedFollow/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('programmatic Context layer changes cannot bypass explicit expansion policy', () => {
|
test('programmatic Context layer changes cannot bypass explicit expansion policy', () => {
|
||||||
@@ -1171,7 +1171,7 @@ test('cockpit route direction uses self-contained vector artwork, not a font lig
|
|||||||
test('cockpit aircraft handoff invalidates the prior world-position anchor', () => {
|
test('cockpit aircraft handoff invalidates the prior world-position anchor', () => {
|
||||||
const match = _adoptTrackedEntity.toString();
|
const match = _adoptTrackedEntity.toString();
|
||||||
assert.ok(match, 'cockpit tracked-entity handoff block is missing');
|
assert.ok(match, 'cockpit tracked-entity handoff block is missing');
|
||||||
assert.match(match, /this\.viewer\.trackedEntity = undefined;/);
|
assert.match(match, /stopFollowing\(this\.viewer\);/);
|
||||||
assert.match(match, /this\.cockpitAnchorValid = false;/);
|
assert.match(match, /this\.cockpitAnchorValid = false;/);
|
||||||
assert.match(
|
assert.match(
|
||||||
match,
|
match,
|
||||||
|
|||||||
+1
-1
@@ -306,7 +306,7 @@ export function relativeBearing(bearing, heading) {
|
|||||||
* Resolve which flight layer owns the cockpit's tracked aircraft.
|
* Resolve which flight layer owns the cockpit's tracked aircraft.
|
||||||
*
|
*
|
||||||
* Both layers can briefly describe a tracked aircraft during a cross-layer
|
* Both layers can briefly describe a tracked aircraft during a cross-layer
|
||||||
* handoff, so the viewer's normalized tracked identity (`gevTrackedId`) is the
|
* handoff, so the viewer's normalized tracked identity (the entity record's `trackedId`) is the
|
||||||
* tie-breaker — the same rule `militaryAwareness.currentTrackedFlightSubject`
|
* tie-breaker — the same rule `militaryAwareness.currentTrackedFlightSubject`
|
||||||
* uses. Layer precedence remains the fallback when no normalized identity is
|
* uses. Layer precedence remains the fallback when no normalized identity is
|
||||||
* available, so tracking paths that never stamp one keep working.
|
* available, so tracking paths that never stamp one keep working.
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ test('tracked-identity resolution falls back to layer precedence', () => {
|
|||||||
const civilian = { icao24: 'aaa077' };
|
const civilian = { icao24: 'aaa077' };
|
||||||
const military = { icao24: 'ae01ce' };
|
const military = { icao24: 'ae01ce' };
|
||||||
// No stamped identity (or one that matches neither layer) keeps the historic
|
// No stamped identity (or one that matches neither layer) keeps the historic
|
||||||
// civilian-first order so tracking paths without gevTrackedId still work.
|
// civilian-first order so tracking paths without an entity record trackedId still work.
|
||||||
assert.equal(resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights');
|
assert.equal(resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'satellites:25544' }).layerId,
|
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'satellites:25544' }).layerId,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* Cockpit aircraft metadata itself is resolved by `resolveTrackedAircraftInfo`
|
* Cockpit aircraft metadata itself is resolved by `resolveTrackedAircraftInfo`
|
||||||
* in `cockpitMath.js` — the landed owner of that rule. It keeps layer
|
* in `cockpitMath.js` — the landed owner of that rule. It keeps layer
|
||||||
* precedence as the fallback when no normalized `gevTrackedId` is available,
|
* precedence as the fallback when the entity record has no normalized `trackedId`,
|
||||||
* so tracking paths that never stamp one keep working; `readAircraftInfo` is
|
* so tracking paths that never stamp one keep working; `readAircraftInfo` is
|
||||||
* pinned to it by `cockpitMarkup.test.mjs`. This module owns only the
|
* pinned to it by `cockpitMarkup.test.mjs`. This module owns only the
|
||||||
* multi-step ENTRY TRANSACTION (adopt → enter → roll back).
|
* multi-step ENTRY TRANSACTION (adopt → enter → roll back).
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
enterCockpitWithTracking,
|
enterCockpitWithTracking,
|
||||||
restoreAircraftTrackingOwner,
|
restoreAircraftTrackingOwner,
|
||||||
} from './cockpitTracking.js';
|
} from './cockpitTracking.js';
|
||||||
|
import { readEntityRecord, withEntityRecord } from './data/entityRecords.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Model the two production stamping seams that sit under this transaction:
|
* Model the two production stamping seams that sit under this transaction:
|
||||||
@@ -22,7 +23,7 @@ function stampingWorld({ entryAllowed = true } = {}) {
|
|||||||
layerId,
|
layerId,
|
||||||
trackById(id) {
|
trackById(id) {
|
||||||
if (!trackable) return false;
|
if (!trackable) return false;
|
||||||
world.trackedEntity = { gevTrackedId: `${layerId}:${id}` };
|
world.trackedEntity = withEntityRecord({}, { trackedId: `${layerId}:${id}` });
|
||||||
stamp('trackedEntityChanged'); // the ui.js listener
|
stamp('trackedEntityChanged'); // the ui.js listener
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -80,7 +81,7 @@ test('a refused Cockpit entry still stamps for the tracker it already moved', ()
|
|||||||
// Adoption stamped, and the rollback re-track stamped again.
|
// Adoption stamped, and the rollback re-track stamped again.
|
||||||
assert.deepEqual(world.stamps, ['trackedEntityChanged', 'trackedEntityChanged']);
|
assert.deepEqual(world.stamps, ['trackedEntityChanged', 'trackedEntityChanged']);
|
||||||
assert.equal(world.generation, 2);
|
assert.equal(world.generation, 2);
|
||||||
assert.equal(world.trackedEntity.gevTrackedId, 'military:ae01ce');
|
assert.equal(readEntityRecord(world.trackedEntity)?.trackedId, 'military:ae01ce');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a Cockpit entry that mutates nothing takes no camera authority', () => {
|
test('a Cockpit entry that mutates nothing takes no camera authority', () => {
|
||||||
|
|||||||
+14
-5
@@ -1,4 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { IconSetPrimitive } from './data/iconSetPrimitive.js';
|
||||||
|
|
||||||
// Narrow, version-tested engine adapter. Cesium has no public custom shader
|
// Narrow, version-tested engine adapter. Cesium has no public custom shader
|
||||||
// option for native point/billboard collections. Do not patch its prototypes or
|
// option for native point/billboard collections. Do not patch its prototypes or
|
||||||
@@ -64,9 +65,15 @@ export function sonarVertexSource(source, kind) {
|
|||||||
kind === 'point' ? 'positionHighAndSize' : 'positionHighAndScale';
|
kind === 'point' ? 'positionHighAndSize' : 'positionHighAndScale';
|
||||||
const lowAttribute =
|
const lowAttribute =
|
||||||
kind === 'point' ? 'positionLowAndOutline' : 'positionLowAndRotation';
|
kind === 'point' ? 'positionLowAndOutline' : 'positionLowAndRotation';
|
||||||
|
// Icon sets move rows on the GPU, so their shader publishes the anchor itself.
|
||||||
|
const anchorSource =
|
||||||
|
kind === 'icon'
|
||||||
|
? 'iconAnchorClip'
|
||||||
|
: `czm_modelViewProjectionRelativeToEye * czm_translateRelativeToEye(${attribute}.xyz, ${lowAttribute}.xyz)`;
|
||||||
if (
|
if (
|
||||||
!text.includes(attribute) ||
|
(kind === 'icon'
|
||||||
!text.includes(lowAttribute) ||
|
? !text.includes('iconAnchorClip')
|
||||||
|
: !text.includes(attribute) || !text.includes(lowAttribute)) ||
|
||||||
!text.includes('v_color') ||
|
!text.includes('v_color') ||
|
||||||
!/void\s+main\s*\(\s*\)/.test(text)
|
!/void\s+main\s*\(\s*\)/.test(text)
|
||||||
)
|
)
|
||||||
@@ -83,7 +90,7 @@ void main() {
|
|||||||
gev_sonar_main();
|
gev_sonar_main();
|
||||||
// All corners/glyphs share the contact anchor, before native screen offsets.
|
// All corners/glyphs share the contact anchor, before native screen offsets.
|
||||||
// Leave gl_Position untouched so appearance never changes geometry or picks.
|
// Leave gl_Position untouched so appearance never changes geometry or picks.
|
||||||
vec4 anchor = czm_modelViewProjectionRelativeToEye * czm_translateRelativeToEye(${attribute}.xyz, ${lowAttribute}.xyz);
|
vec4 anchor = ${anchorSource};
|
||||||
if (anchor.w <= 0.0) return;
|
if (anchor.w <= 0.0) return;
|
||||||
vec2 pixel = anchor.xy / anchor.w * u_gevSonarViewport * 0.5;
|
vec2 pixel = anchor.xy / anchor.w * u_gevSonarViewport * 0.5;
|
||||||
float floorValue = u_gevSonar.z;
|
float floorValue = u_gevSonar.z;
|
||||||
@@ -108,7 +115,7 @@ void main() {
|
|||||||
v_color.rgb = v_color.rgb * 0.28 + (vec3(0.62, 0.68, 0.72) + vec3(0.38, 0.16, 0.12) * coverage) * 0.72;
|
v_color.rgb = v_color.rgb * 0.28 + (vec3(0.62, 0.68, 0.72) + vec3(0.38, 0.16, 0.12) * coverage) * 0.72;
|
||||||
// Apply before native fragment pass classification, never after main().
|
// Apply before native fragment pass classification, never after main().
|
||||||
v_color.a *= factor;
|
v_color.a *= factor;
|
||||||
${kind === 'point' ? 'v_outlineColor.a *= factor;' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'}
|
${kind === 'point' ? 'v_outlineColor.a *= factor;' : kind === 'icon' ? '' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
@@ -208,7 +215,9 @@ export function createCyberSonarGpu(scene, readFrame) {
|
|||||||
? 'point'
|
? 'point'
|
||||||
: native.owner instanceof Cesium.BillboardCollection
|
: native.owner instanceof Cesium.BillboardCollection
|
||||||
? 'billboard'
|
? 'billboard'
|
||||||
: null;
|
: native.owner instanceof IconSetPrimitive
|
||||||
|
? 'icon'
|
||||||
|
: null;
|
||||||
if (!kind || !native.shaderProgram || !native.uniformMap) continue;
|
if (!kind || !native.shaderProgram || !native.uniformMap) continue;
|
||||||
const source = native.shaderProgram;
|
const source = native.shaderProgram;
|
||||||
// Label backgrounds are non-SDF billboard batches. The version-tested
|
// Label backgrounds are non-SDF billboard batches. The version-tested
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { TRACKED_ICON_COLOR } from '../layers/military/policy.js';
|
|||||||
import { createRendering as civilRendering } from '../layers/flights/rendering.js';
|
import { createRendering as civilRendering } from '../layers/flights/rendering.js';
|
||||||
import { createRendering as militaryRendering } from '../layers/military/rendering.js';
|
import { createRendering as militaryRendering } from '../layers/military/rendering.js';
|
||||||
import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js';
|
import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js';
|
||||||
|
import { createIcons as createMilitaryIcons } from '../layers/military/icons.js';
|
||||||
|
import { createIconSet } from './iconSet.js';
|
||||||
|
import {
|
||||||
|
aircraftMotionRegime,
|
||||||
|
createMotionRegime,
|
||||||
|
} from './aircraftMotionRegime.js';
|
||||||
import { aircraftEmergency } from './aircraftEmergency.js';
|
import { aircraftEmergency } from './aircraftEmergency.js';
|
||||||
// Source-to-voice provenance, status retention/clearing, contact-time contracts,
|
// Source-to-voice provenance, status retention/clearing, contact-time contracts,
|
||||||
// and civil/military billboard, Cockpit and model tint regressions.
|
// and civil/military billboard, Cockpit and model tint regressions.
|
||||||
@@ -462,18 +468,16 @@ test('tint priorities cover normal, priority, emergency, tracking and Cockpit do
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => {
|
test('GERMAN: an existing untracked military icon repaints each poll without losing its fade', () => {
|
||||||
const { flightState, rendering } = renderHarness(militaryRendering);
|
const { flightState, rendering } = renderHarness(militaryRendering);
|
||||||
const bb = {
|
|
||||||
color: rendering._fleetBillboardColor('abc123').withAlpha(0.65),
|
|
||||||
};
|
|
||||||
Object.assign(flightState, {
|
Object.assign(flightState, {
|
||||||
_billboards: new Map([['abc123', bb]]),
|
_icons: createIconSet(),
|
||||||
_models: new Map(),
|
_models: new Map(),
|
||||||
_positionHistory: new Map(),
|
_positionHistory: new Map(),
|
||||||
_cullPositions: new Map(),
|
_displayCourse: new Map(),
|
||||||
_groundSnap: { forget() {} },
|
_groundSnap: { forget() {} },
|
||||||
_cockpitContactMode: false,
|
_cockpitContactMode: false,
|
||||||
|
_cockpitNearContacts: new Set(),
|
||||||
});
|
});
|
||||||
const records = new MilitaryFlightRecords({
|
const records = new MilitaryFlightRecords({
|
||||||
geoidHeight: () => 0,
|
geoidHeight: () => 0,
|
||||||
@@ -481,18 +485,42 @@ test('GERMAN: an existing untracked military billboard repaints each poll withou
|
|||||||
floorAltitudeM: (value) => value,
|
floorAltitudeM: (value) => value,
|
||||||
});
|
});
|
||||||
flightState.records = records;
|
flightState.records = records;
|
||||||
|
const parts = {
|
||||||
|
rendering: {
|
||||||
|
...rendering,
|
||||||
|
_iconKind: () => 'jet',
|
||||||
|
_syncModelToClass() {},
|
||||||
|
},
|
||||||
|
motion: {
|
||||||
|
_motionRegime: (history, info, renderTime) =>
|
||||||
|
aircraftMotionRegime(
|
||||||
|
history,
|
||||||
|
info,
|
||||||
|
renderTime,
|
||||||
|
{ speed: 'speedMps', track: 'track' },
|
||||||
|
createMotionRegime(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const icons = createMilitaryIcons({
|
||||||
|
flightState,
|
||||||
|
services: { focus: { focusPassIsNeeded: () => false } },
|
||||||
|
parts,
|
||||||
|
});
|
||||||
|
const row = flightState._icons.add('abc123', {
|
||||||
|
layer: 'military',
|
||||||
|
id: 'abc123',
|
||||||
|
});
|
||||||
|
// The focus fade the fleet tick composes lives in its own column.
|
||||||
|
flightState._icons.setFocus(row, 0.65);
|
||||||
const apply = createMilitarySnapshotRenderer({
|
const apply = createMilitarySnapshotRenderer({
|
||||||
flightState,
|
flightState,
|
||||||
records,
|
records,
|
||||||
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
|
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
|
||||||
meshFloor: { sampleMeshFloorCells() {} },
|
meshFloor: { sampleMeshFloorCells() {} },
|
||||||
militaryRegistry: { registerMilitaryIcaos() {} },
|
militaryRegistry: { registerMilitaryIcaos() {} },
|
||||||
rendering: {
|
rendering: parts.rendering,
|
||||||
...rendering,
|
icons,
|
||||||
_syncModelToClass() {},
|
|
||||||
_applyFleetBillboardPresentation() {},
|
|
||||||
},
|
|
||||||
motion: {},
|
|
||||||
tracking: {},
|
tracking: {},
|
||||||
queries: {},
|
queries: {},
|
||||||
});
|
});
|
||||||
@@ -518,7 +546,7 @@ test('GERMAN: an existing untracked military billboard repaints each poll withou
|
|||||||
{ observedAtMs: NOW_MS },
|
{ observedAtMs: NOW_MS },
|
||||||
);
|
);
|
||||||
apply(snapshot, {});
|
apply(snapshot, {});
|
||||||
assert.equal(bb.color.green < 0.5, red);
|
assert.equal(flightState._icons.color(row).green < 0.5, red);
|
||||||
assert.equal(bb.color.alpha, 0.65);
|
assert.ok(Math.abs(flightState._icons.focus(row) - 0.65) < 1e-6);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import {
|
||||||
|
COURSE_HOLD_SPEED_MPS,
|
||||||
|
courseSlewCapDps,
|
||||||
|
norm180,
|
||||||
|
} from './motionModel.js';
|
||||||
|
import {
|
||||||
|
createIconSet,
|
||||||
|
ICON_CULL_LIFT,
|
||||||
|
ICON_RECEDES,
|
||||||
|
ICON_ROTATES,
|
||||||
|
} from './iconSet.js';
|
||||||
|
import {
|
||||||
|
segmentCourse,
|
||||||
|
writeArcSegment,
|
||||||
|
writeCourseGlide,
|
||||||
|
writeHoldSegment,
|
||||||
|
writeLerpSegment,
|
||||||
|
} from './iconSegment.js';
|
||||||
|
import { IconSetPrimitive } from './iconSetPrimitive.js';
|
||||||
|
import { iconAtlas } from './iconAtlas.js';
|
||||||
|
import {
|
||||||
|
aircraftIcon,
|
||||||
|
FLEET_ICON_PX,
|
||||||
|
TRACKED_ICON_PX,
|
||||||
|
} from './aircraftIcons.js';
|
||||||
|
import { cockpitContactDotImage } from './cockpitContactDot.js';
|
||||||
|
import { horizonOccluder } from './iconOrientation.js';
|
||||||
|
|
||||||
|
/** Raster size of the cockpit contact pip (cockpitContactDot.js draws 16 px). */
|
||||||
|
const COCKPIT_DOT_PX = 16;
|
||||||
|
/** Fleet glyphs keep the established close-range 3× scale. */
|
||||||
|
const NORMAL_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(
|
||||||
|
1000,
|
||||||
|
3.0,
|
||||||
|
8000000,
|
||||||
|
0.5,
|
||||||
|
);
|
||||||
|
const COCKPIT_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(
|
||||||
|
1000,
|
||||||
|
1.15,
|
||||||
|
8000000,
|
||||||
|
0.65,
|
||||||
|
);
|
||||||
|
/** Contacts drawn below this height (m) are judged for the horizon a little
|
||||||
|
* above the ellipsoid (ICON_CULL_LIFT). */
|
||||||
|
const CULL_LIFT_BELOW_M = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An aircraft layer's contacts as one icon set: each row's motion segment
|
||||||
|
* mirrors the layer's dead reckoning for the delayed display time, so the GPU
|
||||||
|
* moves every untracked contact between polls with no per-frame CPU pass.
|
||||||
|
*
|
||||||
|
* The layer's state supplies `_icons`, `_iconPrimitive`, `_scratchIconRenderTime`,
|
||||||
|
* `_iconFocusActive`, `_positionHistory`, `_displayCourse`, `_groundSnap`,
|
||||||
|
* `_models`, `_trackedIcao`, `_viewer`, `_cockpitContactMode`,
|
||||||
|
* `_cockpitNearContacts` and `records` (`data`, `missingPolls`, `forget`).
|
||||||
|
* `parts.motion._motionRegime` picks the motion; `parts.rendering` supplies
|
||||||
|
* the icon kind, scale, colours and distance scalars, and `_releaseModel`.
|
||||||
|
*
|
||||||
|
* @param {object} options
|
||||||
|
* @param {object} options.state The layer's mutable state.
|
||||||
|
* @param {object} options.services The layer's services (`focus`).
|
||||||
|
* @param {object} options.parts The layer's parts (`motion`, `rendering`).
|
||||||
|
* @param {string} options.layerId Pick and focus owner id.
|
||||||
|
* @param {number} options.renderDelaySec How far behind real time contacts draw.
|
||||||
|
* @param {number} options.courseMaxDps The fleet's course slew cap.
|
||||||
|
* @param {number} options.cockpitContactSizePx Cockpit far-contact pip size.
|
||||||
|
* @param {(icao24: string) => boolean} [options.holdsPlan] True while something
|
||||||
|
* else writes this row's segment (it is not planned).
|
||||||
|
* @param {(icao24: string) => void} [options.beforeSettle] Runs before a row is
|
||||||
|
* planned afresh.
|
||||||
|
* @param {(icao24: string) => void} [options.afterSettle] Runs after it.
|
||||||
|
* @param {(icao24: string) => void} [options.onForget] Drops the layer's own
|
||||||
|
* per-contact state when a contact goes.
|
||||||
|
*/
|
||||||
|
export function createAircraftIcons({
|
||||||
|
state,
|
||||||
|
services,
|
||||||
|
parts,
|
||||||
|
layerId,
|
||||||
|
renderDelaySec,
|
||||||
|
courseMaxDps,
|
||||||
|
cockpitContactSizePx,
|
||||||
|
holdsPlan = () => false,
|
||||||
|
beforeSettle = () => {},
|
||||||
|
afterSettle = () => {},
|
||||||
|
onForget = () => {},
|
||||||
|
}) {
|
||||||
|
const { focusPassIsNeeded } = services.focus;
|
||||||
|
const scratchTime = new Cesium.JulianDate();
|
||||||
|
const scratchPosition = new Cesium.Cartesian3();
|
||||||
|
const scratchCull = new Cesium.Cartesian3();
|
||||||
|
const scratchScaleByDistance = new Cesium.NearFarScalar();
|
||||||
|
|
||||||
|
/** The delayed instant every untracked contact is drawn at. */
|
||||||
|
function _displayTime(result = scratchTime) {
|
||||||
|
return Cesium.JulianDate.addSeconds(
|
||||||
|
Cesium.JulianDate.now(result),
|
||||||
|
-renderDelaySec,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _displayTau() {
|
||||||
|
return state._icons.seconds(_displayTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create the set and its primitive for one layer lifetime. */
|
||||||
|
function _createIcons(viewer) {
|
||||||
|
state._icons = createIconSet({ epoch: _displayTime() });
|
||||||
|
state._iconPrimitive = new IconSetPrimitive({
|
||||||
|
set: state._icons,
|
||||||
|
renderTime: () => _displayTime(state._scratchIconRenderTime),
|
||||||
|
});
|
||||||
|
viewer.scene.primitives.add(state._iconPrimitive);
|
||||||
|
return state._iconPrimitive;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a contact is drawn right now, or null when it has no row. */
|
||||||
|
function _iconPosition(icao24, result = new Cesium.Cartesian3()) {
|
||||||
|
const set = state._icons;
|
||||||
|
const row = set?.row(icao24) ?? -1;
|
||||||
|
return row < 0 ? null : set.position(row, _displayTau(), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nose direction a contact is drawn with right now. */
|
||||||
|
function _iconCourse(icao24) {
|
||||||
|
const set = state._icons;
|
||||||
|
const row = set?.row(icao24) ?? -1;
|
||||||
|
return row < 0 ? null : set.course(row, _displayTau());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the layer draws this contact's icon (not tracked, not model-owned). */
|
||||||
|
function _iconShown(icao24) {
|
||||||
|
const set = state._icons;
|
||||||
|
const row = set?.row(icao24) ?? -1;
|
||||||
|
return row >= 0 && set.shown(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The camera's horizon test, or null when there is no placed camera. */
|
||||||
|
function _occluder() {
|
||||||
|
const camera = state._viewer?.camera;
|
||||||
|
return camera?.positionWC ? horizonOccluder(camera) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a drawn row at `position` clears the camera's horizon, judged
|
||||||
|
* exactly as the icon shader judges it. No camera, no horizon. */
|
||||||
|
function _aboveHorizon(row, position, occluder) {
|
||||||
|
return (
|
||||||
|
!occluder ||
|
||||||
|
occluder.isPointVisible(
|
||||||
|
state._icons.cullPoint(row, position, scratchCull),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the segment the layer's dead reckoning would follow from now on:
|
||||||
|
* interpolation between the bracketing fixes, the warm-up arc back from the
|
||||||
|
* oldest fix, or the coast arc past the newest. The displayed nose glides
|
||||||
|
* from where it is onto the new course at the fleet's slew cap; a hovering
|
||||||
|
* contact keeps its nose. The row expires when the regime would change.
|
||||||
|
* @param {string} icao24
|
||||||
|
* @param {{fromCourseDeg?: number|null}} [options] Nose to glide from when
|
||||||
|
* the row's own segment is not what was on screen (tracking handoff).
|
||||||
|
*/
|
||||||
|
function _planIcon(icao24, { fromCourseDeg } = {}) {
|
||||||
|
const set = state._icons;
|
||||||
|
const row = set?.row(icao24) ?? -1;
|
||||||
|
if (row < 0 || holdsPlan(icao24)) return;
|
||||||
|
const history = state._positionHistory.get(icao24);
|
||||||
|
if (!history?.length) return;
|
||||||
|
const info = state.records.data.get(icao24);
|
||||||
|
const renderTime = _displayTime();
|
||||||
|
const tau = set.seconds(renderTime);
|
||||||
|
const seg = set.segments;
|
||||||
|
const shownCourse =
|
||||||
|
fromCourseDeg ??
|
||||||
|
(set.expires(row) === -Infinity ? null : set.course(row, tau));
|
||||||
|
|
||||||
|
const regime = parts.motion._motionRegime(history, info, renderTime);
|
||||||
|
let speed;
|
||||||
|
let expiresAt = Infinity;
|
||||||
|
if (regime.kind === 'between') {
|
||||||
|
const { a, b, span, trackFrom, trackTo, chordCourse } = regime;
|
||||||
|
speed = regime.speed;
|
||||||
|
const blend = chordCourse == null ? 0 : regime.blend;
|
||||||
|
if (span > 0) {
|
||||||
|
writeLerpSegment(seg, row, {
|
||||||
|
from: a.position,
|
||||||
|
to: b.position,
|
||||||
|
t0: set.seconds(a.time),
|
||||||
|
t1: set.seconds(b.time),
|
||||||
|
course0Deg:
|
||||||
|
trackFrom + blend * norm180((chordCourse ?? 0) - trackFrom),
|
||||||
|
courseSlopeDeg: norm180(trackTo - trackFrom) * (1 - blend),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
writeHoldSegment(
|
||||||
|
seg,
|
||||||
|
row,
|
||||||
|
b.position,
|
||||||
|
trackTo + blend * norm180((chordCourse ?? 0) - trackTo),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expiresAt = set.seconds(b.time);
|
||||||
|
} else {
|
||||||
|
const { fix, heading } = regime;
|
||||||
|
speed = regime.speed;
|
||||||
|
const t0 = set.seconds(fix.time);
|
||||||
|
if (speed === 0) {
|
||||||
|
writeHoldSegment(seg, row, fix.position, heading);
|
||||||
|
} else {
|
||||||
|
writeArcSegment(seg, row, {
|
||||||
|
fix: fix.position,
|
||||||
|
t0,
|
||||||
|
minDt: regime.minDt,
|
||||||
|
maxDt: regime.maxDt,
|
||||||
|
speedMps: speed,
|
||||||
|
trackDeg: heading,
|
||||||
|
turnRateDps: regime.turnRateDps,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A warm-up's arc ends at its fix; a coast's runs to its limit.
|
||||||
|
if (regime.maxDt === 0) expiresAt = t0;
|
||||||
|
}
|
||||||
|
// Below hover speed the nose holds what is on screen (or, for a new row,
|
||||||
|
// where this segment starts); above it, it glides on at the slew cap.
|
||||||
|
const hold = speed < COURSE_HOLD_SPEED_MPS;
|
||||||
|
if (hold || shownCourse != null) {
|
||||||
|
writeCourseGlide(
|
||||||
|
seg,
|
||||||
|
row,
|
||||||
|
shownCourse ?? segmentCourse(seg, row, tau),
|
||||||
|
tau,
|
||||||
|
hold ? 0 : courseSlewCapDps(speed, courseMaxDps),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
set.segmentWritten(row, expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-plan every row whose regime ended before `tau`. */
|
||||||
|
function _replanExpired(tau) {
|
||||||
|
const set = state._icons;
|
||||||
|
const due = [];
|
||||||
|
for (let row = 0; row < set.count; row++)
|
||||||
|
if (set.expires(row) <= tau) due.push(set.idAt(row));
|
||||||
|
for (const icao24 of due) _planIcon(icao24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance focus de-emphasis for every drawn row (and every row a fleet model
|
||||||
|
* draws for, whose alpha reads it), only while a focus target exists or some
|
||||||
|
* row is still relaxing back to full emphasis.
|
||||||
|
*/
|
||||||
|
function _focusIcons(tau, scene, camera, nowMs, target) {
|
||||||
|
if (!focusPassIsNeeded(target, state._iconFocusActive)) return;
|
||||||
|
const { advanceProjectedSpriteFocus, nearFarScalarValueAtDistance } =
|
||||||
|
services.focus;
|
||||||
|
const set = state._icons;
|
||||||
|
let active = 0;
|
||||||
|
for (let row = 0; row < set.count; row++) {
|
||||||
|
if (!set.shown(row) && !state._models.has(set.idAt(row))) continue;
|
||||||
|
const position = set.position(row, tau, scratchPosition);
|
||||||
|
const distanceScale = nearFarScalarValueAtDistance(
|
||||||
|
set.scaleByDistance(row, scratchScaleByDistance),
|
||||||
|
Cesium.Cartesian3.distance(camera.positionWC, position),
|
||||||
|
);
|
||||||
|
const half = set.scale(row) * distanceScale * 0.5;
|
||||||
|
const focus = advanceProjectedSpriteFocus(
|
||||||
|
set.metaAt(row),
|
||||||
|
position,
|
||||||
|
scene,
|
||||||
|
camera,
|
||||||
|
nowMs,
|
||||||
|
target,
|
||||||
|
undefined,
|
||||||
|
set.width(row) * half,
|
||||||
|
set.height(row) * half,
|
||||||
|
);
|
||||||
|
set.setFocus(row, focus.factor);
|
||||||
|
if (focus.active || focus.transitioning) active++;
|
||||||
|
}
|
||||||
|
state._iconFocusActive = active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Image, size, scale, tint and freshness for one row under the current
|
||||||
|
* normal/cockpit contract. Motion and rotation stay with the segment. */
|
||||||
|
function _applyIconPresentation(icao24) {
|
||||||
|
const set = state._icons;
|
||||||
|
const row = set?.row(icao24) ?? -1;
|
||||||
|
if (row < 0) return;
|
||||||
|
const rendering = parts.rendering;
|
||||||
|
const info = state.records.data.get(icao24);
|
||||||
|
const tracked = icao24 === state._trackedIcao;
|
||||||
|
const cockpitDot =
|
||||||
|
state._cockpitContactMode &&
|
||||||
|
!tracked &&
|
||||||
|
!state._cockpitNearContacts.has(icao24);
|
||||||
|
if (cockpitDot) {
|
||||||
|
const dot = iconAtlas.slot(cockpitContactDotImage(), COCKPIT_DOT_PX);
|
||||||
|
set.setImage(row, dot, dot);
|
||||||
|
set.setSize(row, cockpitContactSizePx, cockpitContactSizePx);
|
||||||
|
set.setScale(row, 1);
|
||||||
|
set.setScaleByDistance(row, COCKPIT_SCALE_BY_DISTANCE);
|
||||||
|
set.setColor(row, rendering._cockpitContactColor(icao24, info));
|
||||||
|
set.setFlag(row, ICON_ROTATES, false);
|
||||||
|
} else {
|
||||||
|
set.setImage(
|
||||||
|
row,
|
||||||
|
iconAtlas.slot(
|
||||||
|
aircraftIcon(rendering._iconKind(icao24, info?.klass)),
|
||||||
|
FLEET_ICON_PX,
|
||||||
|
),
|
||||||
|
iconAtlas.slot(
|
||||||
|
aircraftIcon(
|
||||||
|
rendering._iconKind(icao24, info?.klass),
|
||||||
|
TRACKED_ICON_PX,
|
||||||
|
),
|
||||||
|
TRACKED_ICON_PX,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const size = tracked ? 24 : 20;
|
||||||
|
set.setSize(row, size, size);
|
||||||
|
set.setScale(row, rendering._fleetBillboardScale(icao24, info?.klass));
|
||||||
|
set.setScaleByDistance(row, NORMAL_SCALE_BY_DISTANCE);
|
||||||
|
set.setColor(row, rendering._fleetBillboardColor(icao24, info));
|
||||||
|
set.setFlag(row, ICON_ROTATES, true);
|
||||||
|
}
|
||||||
|
set.setFlag(row, ICON_RECEDES, true);
|
||||||
|
set.setFlag(
|
||||||
|
row,
|
||||||
|
ICON_CULL_LIFT,
|
||||||
|
Number.isFinite(info?.renderAltitudeM) &&
|
||||||
|
info.renderAltitudeM < CULL_LIFT_BELOW_M,
|
||||||
|
);
|
||||||
|
set.setAlpha(row, state.records.missingPolls.get(icao24) ? 0.45 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a row for a new contact, hidden while it is the tracked one. */
|
||||||
|
function _addIcon(icao24) {
|
||||||
|
const set = state._icons;
|
||||||
|
const row = set.add(icao24, { layer: layerId, id: icao24 });
|
||||||
|
_applyIconPresentation(icao24);
|
||||||
|
_settleIcon(icao24);
|
||||||
|
set.setShown(row, icao24 !== state._trackedIcao);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan a row afresh, with the layer's own settle work on either side, and
|
||||||
|
* let a dimmed row relax: one that sat out the focus pass (tracked) may come
|
||||||
|
* back dimmed after the pass went idle, so it is counted to run the pass.
|
||||||
|
* @param {string} icao24
|
||||||
|
* @param {{fromCourseDeg?: number}} [options] The course on screen to glide from.
|
||||||
|
*/
|
||||||
|
function _settleIcon(icao24, options) {
|
||||||
|
const set = state._icons;
|
||||||
|
const row = set?.row(icao24) ?? -1;
|
||||||
|
if (row < 0) return;
|
||||||
|
beforeSettle(icao24);
|
||||||
|
if (set.focus(row) < 1) state._iconFocusActive += 1;
|
||||||
|
_planIcon(icao24, options);
|
||||||
|
afterSettle(icao24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop a contact and everything the layer keeps for it. */
|
||||||
|
function _forgetContact(icao24) {
|
||||||
|
state._icons?.remove(icao24);
|
||||||
|
parts.rendering._releaseModel(icao24);
|
||||||
|
state.records.forget(icao24);
|
||||||
|
state._positionHistory.delete(icao24);
|
||||||
|
state._displayCourse.delete(icao24);
|
||||||
|
state._groundSnap.forget(icao24);
|
||||||
|
onForget(icao24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Icon ids, so ray samples never land on an aircraft instead of the ground. */
|
||||||
|
function _iconIds() {
|
||||||
|
const set = state._icons;
|
||||||
|
const ids = new Array(set?.count ?? 0);
|
||||||
|
for (let row = 0; row < ids.length; row++) ids[row] = set.idAt(row);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_displayTime,
|
||||||
|
_displayTau,
|
||||||
|
_createIcons,
|
||||||
|
_iconPosition,
|
||||||
|
_iconCourse,
|
||||||
|
_iconShown,
|
||||||
|
_occluder,
|
||||||
|
_aboveHorizon,
|
||||||
|
_planIcon,
|
||||||
|
_settleIcon,
|
||||||
|
_replanExpired,
|
||||||
|
_focusIcons,
|
||||||
|
_applyIconPresentation,
|
||||||
|
_addIcon,
|
||||||
|
_forgetContact,
|
||||||
|
_iconIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test seams over an aircraft layer's icon rows, shared by the flights and
|
||||||
|
* military `testing` parts.
|
||||||
|
* @param {object} state The layer state holding `_icons`.
|
||||||
|
* @param {{icons: ReturnType<typeof createAircraftIcons>}} parts Read at call time.
|
||||||
|
* @param {string} layerId
|
||||||
|
*/
|
||||||
|
export function aircraftIconTestSeams(state, parts, layerId) {
|
||||||
|
/** Give a test contact an icon row held at `icon.position`, shown as
|
||||||
|
* `icon.show` says (hidden by default while tracked). */
|
||||||
|
function _seedIconForTest(icao24, icon, tracked) {
|
||||||
|
const set = state._icons;
|
||||||
|
const row = set.add(icao24, { layer: layerId, id: icao24 });
|
||||||
|
if (icon?.position) writeHoldSegment(set.segments, row, icon.position, 0);
|
||||||
|
set.segmentWritten(row);
|
||||||
|
set.setShown(row, icon?.show ?? !tracked);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a contact's icon is drawn now and whether the layer shows it. */
|
||||||
|
function _iconForTest(icao24) {
|
||||||
|
const position = parts.icons._iconPosition(icao24);
|
||||||
|
return position
|
||||||
|
? {
|
||||||
|
position,
|
||||||
|
shown: parts.icons._iconShown(icao24),
|
||||||
|
course: parts.icons._iconCourse(icao24),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show or hide a contact's icon as a handoff would, or hold it somewhere. */
|
||||||
|
function _setIconForTest(icao24, { shown, position } = {}) {
|
||||||
|
const set = state._icons;
|
||||||
|
const row = set?.row(icao24) ?? -1;
|
||||||
|
if (row < 0) return;
|
||||||
|
if (shown !== undefined) set.setShown(row, shown);
|
||||||
|
if (position) {
|
||||||
|
writeHoldSegment(set.segments, row, position, 0);
|
||||||
|
set.segmentWritten(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { _seedIconForTest, _iconForTest, _setIconForTest };
|
||||||
|
}
|
||||||
@@ -252,6 +252,8 @@ const _b64 = (s) =>
|
|||||||
* NEAR the display size lets the browser's SVG AA do the work: crisp AND
|
* NEAR the display size lets the browser's SVG AA do the work: crisp AND
|
||||||
* smooth. 64 covers the 40–58 px fleet band with ≤1.6× minification. */
|
* smooth. 64 covers the 40–58 px fleet band with ≤1.6× minification. */
|
||||||
const FLEET_RASTER_PX = 64;
|
const FLEET_RASTER_PX = 64;
|
||||||
|
/** Raster size of the default fleet glyph. */
|
||||||
|
export const FLEET_ICON_PX = FLEET_RASTER_PX;
|
||||||
/** Tracked raster: the tracked billboard is the one SUSTAINED large 2D glyph
|
/** Tracked raster: the tracked billboard is the one SUSTAINED large 2D glyph
|
||||||
* (close-zoom fleet flybys hand off to 3D models). Keep the 192 px texture so
|
* (close-zoom fleet flybys hand off to 3D models). Keep the 192 px texture so
|
||||||
* it stays crisp at its biggest on-screen sizes. */
|
* it stays crisp at its biggest on-screen sizes. */
|
||||||
|
|||||||
@@ -0,0 +1,888 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { selectModelEligible } from './modelEligibility.js';
|
||||||
|
import { horizonOccluder } from './iconOrientation.js';
|
||||||
|
import { aircraftTreatmentAlpha } from './aircraftRecession.js';
|
||||||
|
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js';
|
||||||
|
import {
|
||||||
|
createModelRecord,
|
||||||
|
modelIsRendering,
|
||||||
|
placeModel,
|
||||||
|
showModel,
|
||||||
|
} from './modelRecord.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An aircraft layer's 3D models: the fleet regime and its gap-proof handoff
|
||||||
|
* from the icon set (`createAircraftIcons`), the standalone tracked model, and
|
||||||
|
* the ~12 Hz fleet tick that drives both. Shared by the flights and military
|
||||||
|
* layers, which differ only in the options below.
|
||||||
|
* @param {object} options
|
||||||
|
* @param {object} options.state The layer state.
|
||||||
|
* @param {object} options.services
|
||||||
|
* @param {object} options.parts The layer's parts; `icons`, `motion` and
|
||||||
|
* `tracking` are read at call time.
|
||||||
|
* @param {(url: string) => string} options.resolveAsset
|
||||||
|
* @param {string} options.logLabel Console prefix, e.g. `Data:Flights`.
|
||||||
|
* @param {object} options.policy The layer's model budgets, radii, pixel
|
||||||
|
* bounds, heading offset, IR reload batch and fleet tick interval.
|
||||||
|
* @param {(klass: string) => object} options.modelSpec The class's GLB, scale,
|
||||||
|
* geometry and tint blend; `headingOffsetDeg` overrides the policy's.
|
||||||
|
* @param {(icao24: string) => Cesium.Color} options.modelColor Model tint,
|
||||||
|
* including the tracked contact's.
|
||||||
|
* @param {(icao24: string) => void} [options.onModelEligible] Runs when a
|
||||||
|
* contact is about to render in 3D.
|
||||||
|
* @param {(renderTime: Cesium.JulianDate, tau: number, nowMs: number,
|
||||||
|
* camera: Cesium.Camera) => void} [options.afterReplan] The layer's own pass
|
||||||
|
* in the fleet tick, after expired rows are re-planned.
|
||||||
|
*/
|
||||||
|
export function createAircraftModels({
|
||||||
|
state,
|
||||||
|
services,
|
||||||
|
parts,
|
||||||
|
resolveAsset,
|
||||||
|
logLabel,
|
||||||
|
policy,
|
||||||
|
modelSpec: _modelSpec,
|
||||||
|
modelColor: _modelColor,
|
||||||
|
onModelEligible = () => {},
|
||||||
|
afterReplan = () => {},
|
||||||
|
}) {
|
||||||
|
const { isTr3b } = services.aircraftPresentation;
|
||||||
|
const { trackedModelScaleForPixelCap } = services.camera;
|
||||||
|
const { focusNowMs, getFocusTarget } = services.focus;
|
||||||
|
const { applyAircraftModelTreatment } = services.recession;
|
||||||
|
|
||||||
|
/** The model heading offset a class's GLB needs. */
|
||||||
|
const _headingOffsetDeg = (klass) =>
|
||||||
|
_modelSpec(klass).headingOffsetDeg ?? policy.MODEL_HEADING_OFFSET_DEG;
|
||||||
|
|
||||||
|
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
||||||
|
* ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the
|
||||||
|
* budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in
|
||||||
|
* view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms
|
||||||
|
* `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED
|
||||||
|
* contact does not route through here: it is one model, it is what the camera is aimed at,
|
||||||
|
* and it takes its own default-on, hysteretic zoom regime
|
||||||
|
* (`_trackedModelRegimeActive`). */
|
||||||
|
|
||||||
|
function _modelRegimeActive() {
|
||||||
|
if (!state._models3dEnabled) return false;
|
||||||
|
const h = state._viewer?.camera?.positionCartographic?.height ?? Infinity;
|
||||||
|
return h < policy.MODEL_ALT_CEIL_M;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the
|
||||||
|
* SAME value, else 'all' (MODEL_MAX_ALL) would mark planes eligible that _ensureModel then refuses
|
||||||
|
* at the lower MODEL_MAX, silently degrading 'all' to 'proximity'. */
|
||||||
|
|
||||||
|
function _modelCap() {
|
||||||
|
const mapCap =
|
||||||
|
state._models3dMode === 'all' ? policy.MODEL_MAX_ALL : policy.MODEL_MAX;
|
||||||
|
// `Math.min` on purpose: cockpit may only ever LOWER the GLB budget. Cockpit is
|
||||||
|
// already the heaviest mode (20 Hz camera setView ahead of scene update, photoreal
|
||||||
|
// retraversal, the cloud pass) and every model is its own draw call.
|
||||||
|
return state._cockpitContactMode
|
||||||
|
? Math.min(policy.COCKPIT_MODEL_MAX, mapCap)
|
||||||
|
: mapCap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active ADD radius (m) — new planes inside this range get a model. Mode-aware: 'all' reaches far. */
|
||||||
|
|
||||||
|
function _modelAddDistM() {
|
||||||
|
return state._models3dMode === 'all'
|
||||||
|
? policy.MODEL_ALL_ADD_M
|
||||||
|
: policy.MODEL_PROX_ADD_M;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active KEEP radius (m) — a modeled plane keeps its model out to here (hysteresis vs ADD). */
|
||||||
|
|
||||||
|
function _modelKeepDistM() {
|
||||||
|
return state._models3dMode === 'all'
|
||||||
|
? policy.MODEL_ALL_KEEP_M
|
||||||
|
: policy.MODEL_PROX_KEEP_M;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into
|
||||||
|
* `result` (a shared scratch by default) and returns it. Push it with `placeModel`, which copies
|
||||||
|
* it into the model's record and its own matrix, so models never share one mutable matrix
|
||||||
|
* object. `Model.modelMatrix` is a plain field (not a cloning setter); Cesium
|
||||||
|
* clones it per frame in updateModelMatrix(). Sharing a single scratch made every model render at
|
||||||
|
* the LAST-written transform — all stacked on one plane — and, once the tracked model wrote the
|
||||||
|
* scratch every frame, the stack point oscillated frame-to-frame: the "flickering like mad" bug. */
|
||||||
|
|
||||||
|
function _modelMatrix(
|
||||||
|
pos,
|
||||||
|
headingDeg,
|
||||||
|
result = state._scratchModelMtx,
|
||||||
|
offsetDeg = policy.MODEL_HEADING_OFFSET_DEG,
|
||||||
|
) {
|
||||||
|
state._scratchModelHpr.heading = Cesium.Math.toRadians(
|
||||||
|
(headingDeg || 0) + offsetDeg,
|
||||||
|
);
|
||||||
|
state._scratchModelHpr.pitch = 0;
|
||||||
|
state._scratchModelHpr.roll = 0;
|
||||||
|
return Cesium.Transforms.headingPitchRollToFixedFrame(
|
||||||
|
pos,
|
||||||
|
state._scratchModelHpr,
|
||||||
|
Cesium.Ellipsoid.WGS84,
|
||||||
|
undefined,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything scene.sampleHeight must NOT hit when snapping a grounded model: the
|
||||||
|
* vertical pick ray at a plane's own lat/lon otherwise lands on its (or a parked
|
||||||
|
* neighbor's) billboard/model instead of the tile skin. Cesium's ray-pick exclusion
|
||||||
|
* matches picked-object IDs, and every icon AND model in this layer carries its
|
||||||
|
* icao as `id`, so the icao strings cover both; the tracked entity is excluded as the
|
||||||
|
* object itself. Built lazily — only when a sample actually fires (one-shot). */
|
||||||
|
|
||||||
|
function _groundSampleExclusions() {
|
||||||
|
const out = parts.icons._iconIds();
|
||||||
|
if (state._trackedEntity) out.push(state._trackedEntity);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Position a 3D MODEL renders at. Airborne planes use their dead-reckoned position
|
||||||
|
* verbatim. GROUNDED planes' meta altitude is last-known baro or 0 m — nowhere near
|
||||||
|
* the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland airports,
|
||||||
|
* hovering ~30 m at sea-level ones), and unlike the ground billboards a depth-tested
|
||||||
|
* model can't hide behind disableDepthTestDistance. So a modeled grounded plane rides
|
||||||
|
* a ONE-SHOT cached scene.sampleHeight of the skin at its lat/lon (groundSnap.js,
|
||||||
|
* CCTV-B9b discipline: never per-frame; taxiing >50 m retires the cached value to a
|
||||||
|
* bounded last-known and resamples), plus
|
||||||
|
* the belly offset so it sits on its gear rather than sinking to the model-origin
|
||||||
|
* fuselage centerline. Until the FIRST sample lands (tiles streaming / sample miss)
|
||||||
|
* there is no safe depth-tested placement at all: this returns null, the caller
|
||||||
|
* keeps the 2D billboard visible and the model hidden, and it retries later. Once
|
||||||
|
* a contact has resolved once, a later outage holds that measurement inside
|
||||||
|
* groundSnap's drift bound instead — a taxiing aircraft does not pop back to 2D
|
||||||
|
* because a resample is mid-backoff. */
|
||||||
|
|
||||||
|
function _modelDisplayPosition(icao24, pos, result) {
|
||||||
|
const meta = state.records.data.get(icao24);
|
||||||
|
if (!meta || !meta.onGround) return pos;
|
||||||
|
const h = state._groundSnap.heightFor(
|
||||||
|
state._viewer,
|
||||||
|
icao24,
|
||||||
|
pos,
|
||||||
|
_groundSampleExclusions,
|
||||||
|
);
|
||||||
|
if (h == null) return null;
|
||||||
|
const carto = Cesium.Cartographic.fromCartesian(
|
||||||
|
pos,
|
||||||
|
Cesium.Ellipsoid.WGS84,
|
||||||
|
state._scratchGroundCarto,
|
||||||
|
);
|
||||||
|
carto.height = h + _modelSpec(meta.klass).bellyM;
|
||||||
|
return Cesium.Cartesian3.fromRadians(
|
||||||
|
carto.longitude,
|
||||||
|
carto.latitude,
|
||||||
|
carto.height,
|
||||||
|
Cesium.Ellipsoid.WGS84,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Atomically hand one fleet contact from its icon to a safely placed,
|
||||||
|
* ready 3D model. Missing/loading models and unresolved terrain always leave
|
||||||
|
* the icon owning the visual, so no render frame can hide both.
|
||||||
|
* `beforeShow` runs only on the committing path — the per-tick model treatment
|
||||||
|
* belongs to a model that is about to draw, not to one still waiting. */
|
||||||
|
|
||||||
|
function _driveFleetModelHandoff(
|
||||||
|
icao24,
|
||||||
|
record,
|
||||||
|
row,
|
||||||
|
pos,
|
||||||
|
course,
|
||||||
|
beforeShow,
|
||||||
|
) {
|
||||||
|
const icons = state._icons;
|
||||||
|
if (!record) {
|
||||||
|
icons.setShown(row, true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const displayPos = _modelDisplayPosition(
|
||||||
|
icao24,
|
||||||
|
pos,
|
||||||
|
state._scratchGroundPos,
|
||||||
|
);
|
||||||
|
if (!displayPos) {
|
||||||
|
showModel(record, false); // no ground evidence → nothing safe to place a depth-tested model at
|
||||||
|
icons.setShown(row, true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// The matrix is written BEFORE the readiness test on purpose: a model can flip
|
||||||
|
// `ready` during scene update after this tick, and a first rendered frame on a
|
||||||
|
// stale load-start matrix is the one-frame jump this ordering prevents.
|
||||||
|
placeModel(
|
||||||
|
record,
|
||||||
|
_modelMatrix(
|
||||||
|
displayPos,
|
||||||
|
course,
|
||||||
|
undefined,
|
||||||
|
_headingOffsetDeg(state.records.data.get(icao24)?.klass),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!record.model.ready) {
|
||||||
|
showModel(record, false); // not loaded yet → keep the 2D icon, no half-model flash
|
||||||
|
icons.setShown(row, true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
beforeShow?.();
|
||||||
|
if (!record.show) showModel(record, true);
|
||||||
|
icons.setShown(row, false); // hand off ONLY once the model renders
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a 3D model — not the billboard — is what the user actually SEES for
|
||||||
|
* this contact, and therefore whether the display floor should stand aside.
|
||||||
|
*
|
||||||
|
* Model EXISTENCE is not ownership. A fleet model that is still loading, or has
|
||||||
|
* no resolved ground to stand on, stays hidden while `bb.show` stays true (the
|
||||||
|
* gap-proof handoff: "hand off ONLY once the model renders"), and a tracked model
|
||||||
|
* is retained but hidden whenever the model regime is off — 3D disabled, camera
|
||||||
|
* zoomed past the ceiling, or cockpit mode. Gating on `has()`/existence therefore
|
||||||
|
* suppressed the clamp in exactly the states where the BILLBOARD is the visual,
|
||||||
|
* putting the burial straight back.
|
||||||
|
*
|
||||||
|
* Cesium's own default `show === true` is the reason admission clears it
|
||||||
|
* explicitly: a fleet model registered in `_models` the instant its glTF resolves
|
||||||
|
* would otherwise claim ownership — at the identity matrix — for the frames
|
||||||
|
* between admission and the next fleet tick, while the BILLBOARD was still the
|
||||||
|
* visual. Ownership means actually rendering, and every site that sets `show`
|
||||||
|
* true does so only after committing a matrix.
|
||||||
|
*
|
||||||
|
* The two halves:
|
||||||
|
* - Fleet: the rendering test alone. Safe for the ground snap either way,
|
||||||
|
* because a fleet model is positioned from the RAW dead-reckon, not from
|
||||||
|
* the billboard.
|
||||||
|
* - Tracked: the rendering test AND `_trackedModelRegimeActive()`. The tracked
|
||||||
|
* model is fed from `_trackedDisplayPosition`, so once it is live the clamp
|
||||||
|
* must stand aside: two different chains would otherwise be deciding one
|
||||||
|
* contact's ground, and the billboard's is the one the operator is not
|
||||||
|
* looking at (T7). The regime check is what makes a regime
|
||||||
|
* flip take effect on the same frame rather than waiting for
|
||||||
|
* `_updateTrackedModel` to clear `show`; the rendering test is what keeps a
|
||||||
|
* null or still-loading tracked model from claiming a visual it is not
|
||||||
|
* drawing yet.
|
||||||
|
* @param {string} icao24
|
||||||
|
* @param {import('./modelRecord.js').ModelRecord|null} [record] Resolved
|
||||||
|
* fleet model record; null means absent.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function _modelOwnsVisual(icao24, record) {
|
||||||
|
if (icao24 === state._trackedIcao) {
|
||||||
|
return (
|
||||||
|
parts.tracking._trackedModelRegimeActive() &&
|
||||||
|
modelIsRendering(state._trackedModel)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return modelIsRendering(
|
||||||
|
record === undefined ? state._models.get(icao24) : record,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A model is the visual only once it actually draws, loaded AND shown
|
||||||
|
// (`modelIsRendering`). `show` is sufficient evidence of a safe placement
|
||||||
|
// because only ONE site ever sets it true for a fleet model
|
||||||
|
// (`_driveFleetModelHandoff`, after the matrix is committed) and one for the
|
||||||
|
// tracked model (`_updateTrackedModel`, likewise); everything else —
|
||||||
|
// admission, an unresolved ground, a not-yet-ready glTF, the limb cull, a
|
||||||
|
// regime exit — only ever clears it.
|
||||||
|
|
||||||
|
/** Spec identity for a LOADED model: URL and scale together (same-URL classes
|
||||||
|
* differ by scale — airliner vs quadjet both ship airplane.glb). */
|
||||||
|
|
||||||
|
const _specKeyFor = (klass) => {
|
||||||
|
const spec = _modelSpec(klass);
|
||||||
|
return `${spec.url}@${spec.scale}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Class-change model sync (enrichment AND poll-path klass updates): when the
|
||||||
|
* aircraft's live model or in-flight load no longer matches its class's spec,
|
||||||
|
* drop it so the eligibility pass reloads the right asset at the right scale.
|
||||||
|
* Gap-proof: the fleet billboard is re-shown BEFORE the release so the
|
||||||
|
* contact never goes invisible for the tick gap; _releaseModel's generation
|
||||||
|
* bump also invalidates any pending load. The tracked standalone model gets
|
||||||
|
* the same rule (its billboard entity is always the fallback visual). */
|
||||||
|
|
||||||
|
function _syncModelToClass(icao24) {
|
||||||
|
const key = _specKeyFor(state.records.data.get(icao24)?.klass);
|
||||||
|
const current = state._models.get(icao24);
|
||||||
|
if (
|
||||||
|
(current && current.specKey !== key) ||
|
||||||
|
(!current && state._modelPending.has(icao24))
|
||||||
|
) {
|
||||||
|
_releaseModel(icao24);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
icao24 === state._trackedIcao &&
|
||||||
|
state._trackedModel &&
|
||||||
|
state._trackedModel.specKey !== key
|
||||||
|
) {
|
||||||
|
_releaseTrackedModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _reloadModelsForIrBoost() {
|
||||||
|
state._irReloadQueue = [...state._models.keys()];
|
||||||
|
for (const icao of state._modelPending) {
|
||||||
|
if (!state._models.has(icao))
|
||||||
|
state._modelGen.set(icao, (state._modelGen.get(icao) || 0) + 1);
|
||||||
|
}
|
||||||
|
_releaseTrackedModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _drainIrReloadQueue() {
|
||||||
|
if (!state._irReloadQueue) return;
|
||||||
|
const batch = state._irReloadQueue.splice(0, policy.IR_RELOAD_BATCH);
|
||||||
|
for (const icao of batch) {
|
||||||
|
const record = state._models.get(icao);
|
||||||
|
if (!record || record.irBoost === state._irBoost) continue; // already right state
|
||||||
|
_releaseModel(icao);
|
||||||
|
}
|
||||||
|
if (state._irReloadQueue.length === 0) state._irReloadQueue = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */
|
||||||
|
|
||||||
|
async function _ensureModel(icao24) {
|
||||||
|
// Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet
|
||||||
|
// tick skips it, so a model here would be orphaned + double-rendered.
|
||||||
|
if (icao24 === state._trackedIcao) return;
|
||||||
|
onModelEligible(icao24);
|
||||||
|
if (state._models.has(icao24) || state._modelPending.has(icao24)) return;
|
||||||
|
// Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads
|
||||||
|
// (the cap is rechecked post-await too, before the add). Mode-aware so 'all' can reach MAX_ALL.
|
||||||
|
if (state._models.size + state._modelPending.size >= _modelCap()) return;
|
||||||
|
const epoch = state._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead
|
||||||
|
const gen = state._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale
|
||||||
|
state._modelPending.add(icao24);
|
||||||
|
let model = null;
|
||||||
|
// Spec identity captured at load START — if enrichment reclassifies the
|
||||||
|
// aircraft mid-load, the post-await admission below rejects the stale asset.
|
||||||
|
// Boost state likewise: the creation options bake it in, so a mid-load
|
||||||
|
// toggle must reject too (the reload queue only covers ADMITTED models).
|
||||||
|
const specKey = _specKeyFor(state.records.data.get(icao24)?.klass);
|
||||||
|
const loadIrBoost = state._irBoost;
|
||||||
|
try {
|
||||||
|
const spec = _modelSpec(state.records.data.get(icao24)?.klass);
|
||||||
|
model = await Cesium.Model.fromGltfAsync({
|
||||||
|
url: resolveAsset(spec.url),
|
||||||
|
asynchronous: false,
|
||||||
|
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||||
|
minimumPixelSize: policy.MODEL_MIN_PX,
|
||||||
|
scale: spec.scale,
|
||||||
|
color: state._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
|
||||||
|
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||||
|
// Launch presentation keeps the code-side tint dominant for every approved
|
||||||
|
// model; IR boost removes the remaining diffuse hint with flat UNLIT white.
|
||||||
|
colorBlendAmount: state._irBoost ? 1.0 : spec.blendAmount,
|
||||||
|
customShader: state._irBoost ? state._IR_UNLIT_SHADER : undefined,
|
||||||
|
id: icao24, // so scene.pick returns the icao for click-to-track
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// asset/decode fail — stay an icon. Only touch this lifecycle's state if still current
|
||||||
|
// (a destroy/re-init may have swapped the globals while this load was in flight).
|
||||||
|
if (epoch === state._modelEpoch) {
|
||||||
|
state._modelPending.delete(icao24);
|
||||||
|
_cleanupModelGen(icao24);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new
|
||||||
|
// epoch's _modelPending/_modelGen or add to the new collection — just drop its model.
|
||||||
|
if (epoch !== state._modelEpoch) {
|
||||||
|
try {
|
||||||
|
model.destroy();
|
||||||
|
} catch {
|
||||||
|
/* gone */
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state._modelPending.delete(icao24);
|
||||||
|
// Post-await admission: reject (and DESTROY the loaded model) if anything changed during the
|
||||||
|
// load — a release bumped the generation (track/untrack/remove), the layer toggled off / was
|
||||||
|
// torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled.
|
||||||
|
// Recheck the shared Display 3D toggle and altitude ceiling after the async
|
||||||
|
// load. Cockpit uses the same OFF / Proximity / All contract as map Display.
|
||||||
|
const stale =
|
||||||
|
(state._modelGen.get(icao24) || 0) !== gen ||
|
||||||
|
!_modelRegimeActive() ||
|
||||||
|
!state._modelCollection ||
|
||||||
|
state._modelCollection.isDestroyed() ||
|
||||||
|
!state.records.data.has(icao24) ||
|
||||||
|
icao24 === state._trackedIcao ||
|
||||||
|
state._models.has(icao24) ||
|
||||||
|
state._models.size >= _modelCap() ||
|
||||||
|
// Class reclassified mid-load → this GLB/scale is for the OLD class.
|
||||||
|
_specKeyFor(state.records.data.get(icao24)?.klass) !== specKey ||
|
||||||
|
// IR boost flipped mid-load → this model baked the wrong shader/tint.
|
||||||
|
state._irBoost !== loadIrBoost;
|
||||||
|
if (stale) {
|
||||||
|
try {
|
||||||
|
model.destroy();
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
_cleanupModelGen(icao24); // bound the map
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Keep the pick identity explicit on the resolved primitive. This also
|
||||||
|
// protects injected/custom loaders that do not copy the creation option.
|
||||||
|
model.id = icao24;
|
||||||
|
const record = createModelRecord(model, {
|
||||||
|
specKey, // class-change sync compares against this
|
||||||
|
irBoost: loadIrBoost, // boost-flip reload queue compares against this
|
||||||
|
});
|
||||||
|
// Admitted, not yet the visual. Cesium's default is show=true, which would let
|
||||||
|
// an unplaced primitive claim ownership from the icon for the frames
|
||||||
|
// between admission and the next fleet tick (and draw at the identity matrix,
|
||||||
|
// i.e. the Earth's centre). The handoff turns it on once it has a matrix.
|
||||||
|
showModel(record, false);
|
||||||
|
state._modelCollection.add(model);
|
||||||
|
state._models.set(icao24, record);
|
||||||
|
state._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the 3D model for ONE aircraft (removal / military-suppression / track handoff).
|
||||||
|
* Bumps the load generation so any in-flight load for this icao is rejected on completion. */
|
||||||
|
|
||||||
|
function _releaseModel(icao24) {
|
||||||
|
const record = state._models.get(icao24);
|
||||||
|
const pending = state._modelPending.has(icao24);
|
||||||
|
// Only bump the generation when there's something to invalidate (an in-flight load or a
|
||||||
|
// live model) — so removing never-modeled aircraft doesn't grow _modelGen.
|
||||||
|
if (record || pending) {
|
||||||
|
state._modelGen.set(icao24, (state._modelGen.get(icao24) || 0) + 1);
|
||||||
|
}
|
||||||
|
if (record) {
|
||||||
|
if (state._modelCollection && !state._modelCollection.isDestroyed()) {
|
||||||
|
try {
|
||||||
|
state._modelCollection.remove(record.model);
|
||||||
|
} catch {
|
||||||
|
/* gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state._models.delete(icao24);
|
||||||
|
_showReleasedIcon(icao24);
|
||||||
|
}
|
||||||
|
// Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping
|
||||||
|
// it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so
|
||||||
|
// the resolving load's gen-check still rejects it.
|
||||||
|
_cleanupModelGen(icao24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop an icao's generation entry once nothing references it (no live model, no in-flight
|
||||||
|
* load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */
|
||||||
|
|
||||||
|
function _cleanupModelGen(icao24) {
|
||||||
|
if (!state._modelPending.has(icao24) && !state._models.has(icao24))
|
||||||
|
state._modelGen.delete(icao24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */
|
||||||
|
|
||||||
|
function _releaseModels() {
|
||||||
|
state._irReloadQueue = null; // a full release supersedes any pending boost-flip drain
|
||||||
|
// Invalidate in-flight loads so a completion after this bulk release can't add a model.
|
||||||
|
for (const icao of state._modelPending)
|
||||||
|
state._modelGen.set(icao, (state._modelGen.get(icao) || 0) + 1);
|
||||||
|
if (state._modelCollection && !state._modelCollection.isDestroyed()) {
|
||||||
|
for (const record of state._models.values()) {
|
||||||
|
try {
|
||||||
|
state._modelCollection.remove(record.model);
|
||||||
|
} catch {
|
||||||
|
/* gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const icao24 of state._models.keys()) _showReleasedIcon(icao24);
|
||||||
|
state._models.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A released fleet model hands the visual straight back to its icon (the
|
||||||
|
* tracked contact's entity keeps its own). */
|
||||||
|
|
||||||
|
function _showReleasedIcon(icao24) {
|
||||||
|
const icons = state._icons;
|
||||||
|
const row = icons?.row(icao24) ?? -1;
|
||||||
|
if (row >= 0 && icao24 !== state._trackedIcao) icons.setShown(row, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */
|
||||||
|
|
||||||
|
function _releaseTrackedModel() {
|
||||||
|
state._trackedModelGen++;
|
||||||
|
state._trackedModelLoading = false;
|
||||||
|
if (state._trackedModel) {
|
||||||
|
if (state._modelCollection && !state._modelCollection.isDestroyed()) {
|
||||||
|
try {
|
||||||
|
state._modelCollection.remove(state._trackedModel.model);
|
||||||
|
} catch {
|
||||||
|
/* gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state._trackedModel = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-frame driver for the standalone tracked model. Runs every preUpdate (not the 80 ms fleet
|
||||||
|
* cadence) so the centered plane moves smoothly. The tracked entity stays a pure billboard, so the
|
||||||
|
* follow-camera's bounding sphere is ALWAYS ready — toggling 3D, or tracking while already zoomed
|
||||||
|
* in, can never stall or freeze the centering (the old model-graphic-on-entity failure mode). */
|
||||||
|
|
||||||
|
function _updateTrackedModel() {
|
||||||
|
const active =
|
||||||
|
state._trackedIcao &&
|
||||||
|
parts.tracking._trackedModelRegimeActive() &&
|
||||||
|
state._modelCollection &&
|
||||||
|
!state._modelCollection.isDestroyed();
|
||||||
|
if (!active) {
|
||||||
|
if (state._trackedModel) showModel(state._trackedModel, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ask the frame-cached source directly. If the entity callback already ran,
|
||||||
|
// this is a no-op; if model loading completed between phases, it establishes
|
||||||
|
// this frame's single sample before the model renders. Camera, detection, and
|
||||||
|
// readout consumers then reuse that exact cached position.
|
||||||
|
const pos =
|
||||||
|
parts.motion._trackedDisplayPosition(state._trackedIcao) ||
|
||||||
|
parts.icons._iconPosition(state._trackedIcao);
|
||||||
|
if (!pos) {
|
||||||
|
if (state._trackedModel) showModel(state._trackedModel, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!state._trackedModel &&
|
||||||
|
!state._trackedModelLoading &&
|
||||||
|
parts.tracking._trackedModelLoadAllowed()
|
||||||
|
) {
|
||||||
|
state._trackedModelLoading = true;
|
||||||
|
const gen = state._trackedModelGen;
|
||||||
|
const trackedSpec = _modelSpec(
|
||||||
|
state.records.data.get(state._trackedIcao)?.klass,
|
||||||
|
);
|
||||||
|
const trackedKey = _specKeyFor(
|
||||||
|
state.records.data.get(state._trackedIcao)?.klass,
|
||||||
|
);
|
||||||
|
const trackedIrBoost = state._irBoost;
|
||||||
|
Cesium.Model.fromGltfAsync({
|
||||||
|
url: resolveAsset(trackedSpec.url),
|
||||||
|
asynchronous: false,
|
||||||
|
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||||
|
minimumPixelSize: policy.TRACKED_MODEL_MIN_PX,
|
||||||
|
scale: trackedSpec.scale,
|
||||||
|
color: state._irBoost
|
||||||
|
? Cesium.Color.WHITE
|
||||||
|
: _modelColor(state._trackedIcao),
|
||||||
|
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||||
|
// The tracked aircraft uses the same dominant light tint as the fleet;
|
||||||
|
// IR boost removes the remaining diffuse hint with flat UNLIT white.
|
||||||
|
colorBlendAmount: state._irBoost ? 1.0 : trackedSpec.blendAmount,
|
||||||
|
customShader: state._irBoost ? state._IR_UNLIT_SHADER : undefined,
|
||||||
|
// Pick id (H1): without it, clicking the very plane being tracked read as
|
||||||
|
// EMPTY SPACE (scene.pick → primitive with no id) → an unintended
|
||||||
|
// deselect. With the icao, the click handler recognizes it as ours.
|
||||||
|
id: state._trackedIcao,
|
||||||
|
})
|
||||||
|
.then((m) => {
|
||||||
|
// Untracked / re-tracked / torn down during the load → drop it.
|
||||||
|
if (
|
||||||
|
gen !== state._trackedModelGen ||
|
||||||
|
!state._modelCollection ||
|
||||||
|
state._modelCollection.isDestroyed()
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
m.destroy();
|
||||||
|
} catch {
|
||||||
|
/* gone */
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Class reclassified OR boost flipped mid-load (no release ran —
|
||||||
|
// _trackedModel was still null): drop the stale asset; driver reloads.
|
||||||
|
if (
|
||||||
|
_specKeyFor(state.records.data.get(state._trackedIcao)?.klass) !==
|
||||||
|
trackedKey ||
|
||||||
|
state._irBoost !== trackedIrBoost
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
m.destroy();
|
||||||
|
} catch {
|
||||||
|
/* gone */
|
||||||
|
}
|
||||||
|
state._trackedModelLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Assign after resolution as well as in the creation options so the
|
||||||
|
// standalone primitive always exposes the tracked aircraft pick id.
|
||||||
|
m.id = state._trackedIcao;
|
||||||
|
const record = createModelRecord(m, {
|
||||||
|
specKey: trackedKey, // class-change sync compares against this
|
||||||
|
irBoost: trackedIrBoost,
|
||||||
|
});
|
||||||
|
showModel(record, false); // admitted, not yet the visual — the driver shows it once placed
|
||||||
|
// Seed the world transform before the primitive enters the scene. A model
|
||||||
|
// can become ready+shown between render phases; leaving Cesium's identity
|
||||||
|
// default here produces a one-frame jump to the Earth's center.
|
||||||
|
const currentPos =
|
||||||
|
parts.motion._trackedDisplayCached() ||
|
||||||
|
parts.icons._iconPosition(state._trackedIcao);
|
||||||
|
if (currentPos) {
|
||||||
|
const displayPos = _modelDisplayPosition(
|
||||||
|
state._trackedIcao,
|
||||||
|
currentPos,
|
||||||
|
state._scratchGroundPos,
|
||||||
|
);
|
||||||
|
if (displayPos)
|
||||||
|
placeModel(
|
||||||
|
record,
|
||||||
|
_modelMatrix(
|
||||||
|
displayPos,
|
||||||
|
parts.motion._trackedDisplayCourse(),
|
||||||
|
undefined,
|
||||||
|
trackedSpec.headingOffsetDeg ??
|
||||||
|
policy.MODEL_HEADING_OFFSET_DEG,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state._trackedModel = record;
|
||||||
|
state._trackedModelLoading = false;
|
||||||
|
// A good load retires this selection's failure budget — a contact that
|
||||||
|
// recovers after a transient blip is not one attempt from giving up.
|
||||||
|
state._trackedModelFailIcao = null;
|
||||||
|
state._trackedModelFailCount = 0;
|
||||||
|
state._trackedModelRetryAtMs = 0;
|
||||||
|
state._modelCollection.add(m);
|
||||||
|
state._planeModelLoaded = true; // GLB cached — the tracked billboard can fade out once the model is up
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (gen !== state._trackedModelGen) return; // superseded load — not this selection's failure
|
||||||
|
state._trackedModelLoading = false;
|
||||||
|
parts.tracking._noteTrackedModelLoadFailure(trackedSpec.url, err);
|
||||||
|
});
|
||||||
|
return; // billboard carries the visual until the model is ready
|
||||||
|
}
|
||||||
|
if (state._trackedModel) {
|
||||||
|
// Keep the transform current while GPU resources are still loading. Cesium
|
||||||
|
// can flip ready during scene update after this callback; waiting for ready
|
||||||
|
// here would let that first rendered frame use a stale load-start matrix.
|
||||||
|
const displayPos = _modelDisplayPosition(
|
||||||
|
state._trackedIcao,
|
||||||
|
pos,
|
||||||
|
state._scratchGroundPos,
|
||||||
|
);
|
||||||
|
if (!displayPos) {
|
||||||
|
showModel(state._trackedModel, false); // no ground evidence → the icon carries it
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const spec = _modelSpec(
|
||||||
|
state.records.data.get(state._trackedIcao)?.klass,
|
||||||
|
);
|
||||||
|
placeModel(
|
||||||
|
state._trackedModel,
|
||||||
|
_modelMatrix(
|
||||||
|
displayPos,
|
||||||
|
parts.motion._trackedDisplayCourse(),
|
||||||
|
undefined,
|
||||||
|
spec.headingOffsetDeg ?? policy.MODEL_HEADING_OFFSET_DEG,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!state._trackedModel.model.ready) return;
|
||||||
|
state._trackedModel.model.scale = trackedModelScaleForPixelCap({
|
||||||
|
baseScale: spec.scale,
|
||||||
|
nativeRadiusM: spec.nativeRadiusM,
|
||||||
|
rangeM: Cesium.Cartesian3.distance(
|
||||||
|
state._viewer.camera.positionWC,
|
||||||
|
displayPos,
|
||||||
|
),
|
||||||
|
viewportHeightPx: state._viewer.scene.canvas.clientHeight,
|
||||||
|
fovyRad: state._viewer.camera.frustum.fovy,
|
||||||
|
maximumPixelSize: policy.TRACKED_MODEL_MAX_PX,
|
||||||
|
});
|
||||||
|
showModel(state._trackedModel, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ~12 Hz fleet pass. Motion, rotation, the horizon cull, limb recession and
|
||||||
|
* the two-tier glyph swap all run on the GPU from each row's segment, so this
|
||||||
|
* only re-plans rows whose regime ended, runs the layer's own pass after
|
||||||
|
* that (flights floors grounded contacts), advances focus de-emphasis while
|
||||||
|
* a target exists, and drives the 3D-model handoff inside the model regime.
|
||||||
|
* Driven by scene.preRender.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function _fleetTick() {
|
||||||
|
const icons = state._icons;
|
||||||
|
if (!state._viewer || !icons || !icons.show) return;
|
||||||
|
const scene = state._viewer.scene;
|
||||||
|
const camera = state._viewer.camera;
|
||||||
|
const nowMs = focusNowMs(Date.now());
|
||||||
|
|
||||||
|
if (nowMs - state._lastFleetTickMs < policy.FLEET_DR_INTERVAL_MS) return;
|
||||||
|
state._lastFleetTickMs = nowMs;
|
||||||
|
// Every contact in this pass shares the same delayed display instant.
|
||||||
|
const renderTime = parts.icons._displayTime(state._scratchFleetRenderTime);
|
||||||
|
const tau = icons.seconds(renderTime);
|
||||||
|
|
||||||
|
_drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload
|
||||||
|
if (state._cockpitContactMode) parts.tracking._refreshCockpitNearContacts();
|
||||||
|
parts.icons._replanExpired(tau);
|
||||||
|
afterReplan(renderTime, tau, nowMs, camera);
|
||||||
|
const focusTarget = getFocusTarget();
|
||||||
|
parts.icons._focusIcons(tau, scene, camera, nowMs, focusTarget);
|
||||||
|
|
||||||
|
// 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling.
|
||||||
|
// Drop all models the moment we leave it (toggled off / zoomed out) so icons resume.
|
||||||
|
const useModels = _modelRegimeActive();
|
||||||
|
// Drop live models AND invalidate in-flight loads on leaving the regime (else a load that
|
||||||
|
// resolves after zoom-out could briefly add a model outside the 3D-model regime).
|
||||||
|
if (!useModels && (state._models.size || state._modelPending.size))
|
||||||
|
_releaseModels();
|
||||||
|
if (!useModels) return;
|
||||||
|
|
||||||
|
// 3D-model eligibility: by DISTANCE (the mode's add/keep band), with ON-SCREEN PRIORITY under the
|
||||||
|
// cap. FOUR passes, visible-first, so the slots are spent on what you can see: (1) KEEP on-screen
|
||||||
|
// already-modeled (hysteresis for visible planes); (2) ADD on-screen new inside the add radius,
|
||||||
|
// nearest first; (3) KEEP off-screen already-modeled; (4) ADD off-screen new with leftover slots
|
||||||
|
// (so a plane just off the cone still models — the "planes right next to me aren't 3D" complaint).
|
||||||
|
// Crucially KEEP is SPLIT by frustum: an off-screen retained model (pass 3) can never starve an
|
||||||
|
// on-screen plane that wants one (passes 1–2) — a single KEEP-everything pass could fill the cap
|
||||||
|
// with off-screen retained models. Pure-distance let off-screen planes eat the cap; pure-frustum
|
||||||
|
// filtering dropped near off-screen planes entirely. This does both right.
|
||||||
|
const cap = _modelCap();
|
||||||
|
const camPos = camera.positionWC;
|
||||||
|
const addM = _modelAddDistM();
|
||||||
|
const addDistSq = addM * addM;
|
||||||
|
const keepM = _modelKeepDistM();
|
||||||
|
const keepDistSq = keepM * keepM;
|
||||||
|
const cull = camera.frustum.computeCullingVolume(
|
||||||
|
camPos,
|
||||||
|
camera.directionWC,
|
||||||
|
camera.upWC,
|
||||||
|
);
|
||||||
|
// Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness.
|
||||||
|
const cand = [];
|
||||||
|
for (let row = 0; row < icons.count; row++) {
|
||||||
|
const icao = icons.idAt(row);
|
||||||
|
if (icao === state._trackedIcao) continue;
|
||||||
|
// A converted TR-3B renders as an icon and can never take a model, so
|
||||||
|
// it must not occupy a CAP SLOT either — excluded here at selection time,
|
||||||
|
// not just at the handoff below, or accumulated conversions would starve
|
||||||
|
// ordinary contacts of 3D models. (The handoff guard stays as defence.)
|
||||||
|
if (isTr3b(icao)) continue;
|
||||||
|
// Ground planes compete for model slots like everyone else (owner decision
|
||||||
|
// 2026-07-03: "3D mode is respected regardless of whether a plane is on the
|
||||||
|
// ground or in the air — no distinction"). The cap + nearest-first ordering
|
||||||
|
// below already bound airport clusters; grounded placement is handled by the
|
||||||
|
// one-shot ground snap in _modelDisplayPosition.
|
||||||
|
const position = icons.position(row, tau, state._scratchModelBS.center);
|
||||||
|
const d2 = Cesium.Cartesian3.distanceSquared(camPos, position);
|
||||||
|
if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible
|
||||||
|
cand.push([
|
||||||
|
icao,
|
||||||
|
d2,
|
||||||
|
cull.computeVisibility(state._scratchModelBS) !==
|
||||||
|
Cesium.Intersect.OUTSIDE,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
cand.sort((a, b) => a[1] - b[1]); // nearest first
|
||||||
|
if (cand.length > cap && nowMs - state._lastModelCapWarnMs > 5000) {
|
||||||
|
console.warn(
|
||||||
|
`[${logLabel}] ${cand.length} planes in 3D range; capped at ${cap} (${state._models3dMode}). On-screen planes are prioritized.`,
|
||||||
|
);
|
||||||
|
state._lastModelCapWarnMs = nowMs;
|
||||||
|
}
|
||||||
|
const modelEligible = selectModelEligible(cand, {
|
||||||
|
cap,
|
||||||
|
addDistSq,
|
||||||
|
isModeled: (icao) => state._models.has(icao),
|
||||||
|
});
|
||||||
|
const toRelease = [];
|
||||||
|
for (const icao of state._models.keys()) {
|
||||||
|
if (icao !== state._trackedIcao && !modelEligible.has(icao))
|
||||||
|
toRelease.push(icao);
|
||||||
|
}
|
||||||
|
for (const icao of toRelease) _releaseModel(icao);
|
||||||
|
|
||||||
|
// 3D model takes over from the icon for eligible planes. GAP-PROOF: the icon stays shown
|
||||||
|
// until the model is actually READY to render, so a plane is never both iconless AND
|
||||||
|
// modelless (the "planes vanish when 3D turns on" bug). Position the model every tick
|
||||||
|
// regardless so it's framed the instant it becomes ready. Converted TR-3Bs stay 2D on
|
||||||
|
// purpose: the Easter egg IS the triangle, and there is no GLB for it.
|
||||||
|
const occluder = horizonOccluder(camera);
|
||||||
|
for (const icao24 of modelEligible) {
|
||||||
|
if (isTr3b(icao24)) continue;
|
||||||
|
const row = icons.row(icao24);
|
||||||
|
if (row < 0) continue;
|
||||||
|
const position = icons.position(row, tau, state._scratchFleetPos);
|
||||||
|
if (!parts.icons._aboveHorizon(row, position, occluder)) {
|
||||||
|
// A model that crossed the limb would keep rendering through the
|
||||||
|
// hidden globe at its last matrix; the icon's own cull is on the GPU.
|
||||||
|
const hidden = state._models.get(icao24);
|
||||||
|
if (hidden?.show) showModel(hidden, false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ensureModel(icao24);
|
||||||
|
const record = state._models.get(icao24);
|
||||||
|
const cameraDistanceM = Cesium.Cartesian3.distance(camPos, position);
|
||||||
|
_driveFleetModelHandoff(
|
||||||
|
icao24,
|
||||||
|
record,
|
||||||
|
row,
|
||||||
|
position,
|
||||||
|
icons.course(row, tau),
|
||||||
|
() => {
|
||||||
|
applyAircraftModelTreatment({
|
||||||
|
model: record.model,
|
||||||
|
// IR boost must survive the per-tick treatment write — otherwise
|
||||||
|
// any alpha change would repaint the ordinary tint over the hot
|
||||||
|
// white. Boosted models also skip the recession fade: hot targets
|
||||||
|
// stay full-strength at any range (icons keep their normal
|
||||||
|
// fade — full-opacity glyph walls read as overwhelming).
|
||||||
|
baseColor: state._irBoost
|
||||||
|
? Cesium.Color.WHITE
|
||||||
|
: _modelColor(icao24),
|
||||||
|
alpha: state._irBoost
|
||||||
|
? 1
|
||||||
|
: aircraftTreatmentAlpha({
|
||||||
|
baseAlpha: icons.alpha(row),
|
||||||
|
focusFactor: icons.focus(row),
|
||||||
|
cameraDistanceM,
|
||||||
|
cameraHeightM: camera.positionCartographic?.height,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
_modelRegimeActive,
|
||||||
|
_modelCap,
|
||||||
|
_modelAddDistM,
|
||||||
|
_modelKeepDistM,
|
||||||
|
_modelMatrix,
|
||||||
|
_groundSampleExclusions,
|
||||||
|
_modelDisplayPosition,
|
||||||
|
_driveFleetModelHandoff,
|
||||||
|
_modelOwnsVisual,
|
||||||
|
_specKeyFor,
|
||||||
|
_syncModelToClass,
|
||||||
|
_reloadModelsForIrBoost,
|
||||||
|
_drainIrReloadQueue,
|
||||||
|
_ensureModel,
|
||||||
|
_releaseModel,
|
||||||
|
_cleanupModelGen,
|
||||||
|
_releaseModels,
|
||||||
|
_showReleasedIcon,
|
||||||
|
_releaseTrackedModel,
|
||||||
|
_updateTrackedModel,
|
||||||
|
_fleetTick,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import {
|
||||||
|
courseBetweenCartesians,
|
||||||
|
speedRamp,
|
||||||
|
staleCoastLimitSeconds,
|
||||||
|
} from './motionModel.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which record fields carry an aircraft layer's reported ground speed (m/s)
|
||||||
|
* and track (degrees clockwise from north).
|
||||||
|
* @typedef {{speed: string, track: string}} AircraftRecordFields
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A fix's own ground speed, else the record's. */
|
||||||
|
export function fixSpeedMps(fix, info, fields) {
|
||||||
|
return Number.isFinite(fix.velocity)
|
||||||
|
? fix.velocity
|
||||||
|
: (info && info[fields.speed]) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fix's own track, else the record's. */
|
||||||
|
export function fixTrackDeg(fix, info, fields) {
|
||||||
|
return Number.isFinite(fix.track)
|
||||||
|
? fix.track
|
||||||
|
: (info && info[fields.track]) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A reusable result for `aircraftMotionRegime`. */
|
||||||
|
export function createMotionRegime() {
|
||||||
|
return {
|
||||||
|
kind: 'fix',
|
||||||
|
a: null,
|
||||||
|
b: null,
|
||||||
|
span: 0,
|
||||||
|
speed: 0,
|
||||||
|
trackFrom: 0,
|
||||||
|
trackTo: 0,
|
||||||
|
chordCourse: null,
|
||||||
|
blend: 0,
|
||||||
|
fix: null,
|
||||||
|
heading: 0,
|
||||||
|
turnRateDps: 0,
|
||||||
|
minDt: 0,
|
||||||
|
maxDt: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which motion an aircraft's history gives at `renderTime` — the one choice
|
||||||
|
* the CPU dead reckoning and the icon planner (GPU segment) both draw from.
|
||||||
|
*
|
||||||
|
* - `between`: interpolate the bracketing pair `a`→`b`, no extrapolation
|
||||||
|
* error and no snap-back. The course of the DISPLAYED motion blends the
|
||||||
|
* time-interpolated reported track toward the chord by `blend`: the chord
|
||||||
|
* is only trustworthy when the segment covers real ground (at hover its
|
||||||
|
* direction is GPS jitter; on a slow tight turn it STEPS the whole
|
||||||
|
* per-segment turn at each boundary), and helicopters never use it
|
||||||
|
* (rotorcraft chords are noise-dominated at their typical speeds).
|
||||||
|
* - `fix`: extrapolate `fix` by the elapsed time clamped to [minDt, maxDt].
|
||||||
|
* Warm-up (renderTime predates ALL history: freshly seen or just tracked,
|
||||||
|
* before a poll interval of history exists) runs the OLDEST fix BACKWARD,
|
||||||
|
* preserving the behind-real-time invariant, so as history fills the icon
|
||||||
|
* glides FORWARD into interpolation with no freeze and no backward snap
|
||||||
|
* (holding the oldest fix froze the icon; extrapolating the newest to
|
||||||
|
* wall-clock made it jump back a poll interval when interpolation took
|
||||||
|
* over). Otherwise the newest fix coasts. A feed can still be receiving
|
||||||
|
* fresh contact messages for that aircraft; freezing 60 s after the
|
||||||
|
* position produced a stop → catch-up → stop cadence, so it coasts through
|
||||||
|
* the latest real contact plus a bounded grace, with an absolute cap so a
|
||||||
|
* stale cached feed cannot drift forever.
|
||||||
|
*
|
||||||
|
* @param {Array<object>} history Non-empty, oldest first.
|
||||||
|
* @param {object|undefined} info The contact's record.
|
||||||
|
* @param {Cesium.JulianDate} renderTime
|
||||||
|
* @param {AircraftRecordFields} fields
|
||||||
|
* @param {object} result From `createMotionRegime`; returned filled in.
|
||||||
|
*/
|
||||||
|
export function aircraftMotionRegime(
|
||||||
|
history,
|
||||||
|
info,
|
||||||
|
renderTime,
|
||||||
|
fields,
|
||||||
|
result,
|
||||||
|
) {
|
||||||
|
for (let i = history.length - 1; i >= 1; i--) {
|
||||||
|
const a = history[i - 1];
|
||||||
|
const b = history[i];
|
||||||
|
if (
|
||||||
|
!Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) ||
|
||||||
|
!Cesium.JulianDate.lessThanOrEquals(renderTime, b.time)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const span = Cesium.JulianDate.secondsDifference(b.time, a.time);
|
||||||
|
const chordLenM = Cesium.Cartesian3.distance(a.position, b.position);
|
||||||
|
const speed =
|
||||||
|
span > 0 ? chordLenM / span : (info && info[fields.speed]) || 0;
|
||||||
|
const fallbackTrack = (info && info[fields.track]) || 0;
|
||||||
|
const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack;
|
||||||
|
const blend = info && info.klass === 'helicopter' ? 0 : speedRamp(speed);
|
||||||
|
result.kind = 'between';
|
||||||
|
result.a = a;
|
||||||
|
result.b = b;
|
||||||
|
result.span = span;
|
||||||
|
result.speed = speed;
|
||||||
|
result.trackFrom = trackFrom;
|
||||||
|
result.trackTo = Number.isFinite(b.track) ? b.track : trackFrom;
|
||||||
|
result.chordCourse =
|
||||||
|
blend > 0 ? courseBetweenCartesians(a.position, b.position) : null;
|
||||||
|
result.blend = blend;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const newest = history[history.length - 1];
|
||||||
|
const warming = Cesium.JulianDate.lessThan(renderTime, history[0].time);
|
||||||
|
result.kind = 'fix';
|
||||||
|
result.fix = warming ? history[0] : newest;
|
||||||
|
result.speed = fixSpeedMps(result.fix, info, fields);
|
||||||
|
result.heading = fixTrackDeg(result.fix, info, fields);
|
||||||
|
result.turnRateDps = (info && info.turnRateDps) || 0;
|
||||||
|
result.minDt = -60;
|
||||||
|
// A warm-up ends when real time reaches the oldest fix; a single fix keeps
|
||||||
|
// coasting forward on the same arc.
|
||||||
|
result.maxDt =
|
||||||
|
warming && history.length > 1
|
||||||
|
? 0
|
||||||
|
: staleCoastLimitSeconds({
|
||||||
|
// `epochMs` is captured once when the poll is normalized. Avoid
|
||||||
|
// allocating a Date per aircraft on every 12 Hz fleet tick.
|
||||||
|
fixEpochMs: Number.isFinite(newest.epochMs)
|
||||||
|
? newest.epochMs
|
||||||
|
: Cesium.JulianDate.toDate(newest.time).getTime(),
|
||||||
|
lastContactEpochMs: info?.lastContactEpochMs,
|
||||||
|
// Permit one minute of contact grace but cap any cached-feed drift
|
||||||
|
// at five minutes. Source backoff is exposed separately as a STALE cue.
|
||||||
|
minimumSec: 60,
|
||||||
|
maximumSec: 300,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Limb-relative recession for distant aircraft billboards.
|
* Limb-relative recession for distant aircraft: the factors icon sets apply in
|
||||||
|
* their shader (ICON_RECEDES) and models apply through their colour alpha.
|
||||||
*
|
*
|
||||||
* This multiplies the billboard's ordinary scale, which Cesium then composes
|
* It multiplies an icon's ordinary scale, which composes with its locked
|
||||||
* with the locked NearFarScalar. It never edits that scalar, culls a contact,
|
* NearFarScalar. It never edits that scalar, culls a contact, or allows alpha
|
||||||
* or allows alpha to reach zero.
|
* to reach zero.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
|
export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
|
||||||
@@ -19,13 +20,6 @@ export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
|
|||||||
|
|
||||||
let _params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS };
|
let _params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS };
|
||||||
const _scratchFactors = { scale: 1, alpha: 1, limbRatio: null };
|
const _scratchFactors = { scale: 1, alpha: 1, limbRatio: null };
|
||||||
const _treatmentResult = {
|
|
||||||
scale: 1,
|
|
||||||
alpha: 1,
|
|
||||||
scaleWrites: 0,
|
|
||||||
alphaWrites: 0,
|
|
||||||
factors: _scratchFactors,
|
|
||||||
};
|
|
||||||
|
|
||||||
function clamp(value, min, max) {
|
function clamp(value, min, max) {
|
||||||
return Math.min(max, Math.max(min, value));
|
return Math.min(max, Math.max(min, value));
|
||||||
@@ -178,28 +172,18 @@ export function aircraftRecessionFactors(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Production wire helper: compose base scale/alpha, focus emphasis, and limb
|
* The alpha an aircraft draws with: base × max(combined floor, focus × limb
|
||||||
* recession into one deadband-gated billboard write site.
|
* haze). Icon sets compose the same thing in their shader; models need it here.
|
||||||
* @param {object} input
|
* @param {object} input
|
||||||
* @param {object} input.billboard
|
|
||||||
* @param {number} input.baseScale
|
|
||||||
* @param {number} input.baseAlpha
|
* @param {number} input.baseAlpha
|
||||||
* @param {{withAlpha:(alpha:number)=>object}} input.baseColor
|
|
||||||
* @param {number} input.focusFactor
|
* @param {number} input.focusFactor
|
||||||
* @param {number} input.cameraDistanceM
|
* @param {number} input.cameraDistanceM
|
||||||
* @param {number} input.cameraHeightM
|
* @param {number} input.cameraHeightM
|
||||||
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
|
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
|
||||||
*
|
* @returns {number}
|
||||||
* IMPORTANT: the returned object (including `factors`) is a module-owned
|
|
||||||
* mutable singleton. Consume every needed property before the next call;
|
|
||||||
* never retain it as a snapshot.
|
|
||||||
* @returns {{scale:number,alpha:number,scaleWrites:number,alphaWrites:number,factors:object}}
|
|
||||||
*/
|
*/
|
||||||
export function applyAircraftBillboardTreatment({
|
export function aircraftTreatmentAlpha({
|
||||||
billboard,
|
|
||||||
baseScale,
|
|
||||||
baseAlpha,
|
baseAlpha,
|
||||||
baseColor,
|
|
||||||
focusFactor,
|
focusFactor,
|
||||||
cameraDistanceM,
|
cameraDistanceM,
|
||||||
cameraHeightM,
|
cameraHeightM,
|
||||||
@@ -212,33 +196,13 @@ export function applyAircraftBillboardTreatment({
|
|||||||
tuning,
|
tuning,
|
||||||
_scratchFactors,
|
_scratchFactors,
|
||||||
);
|
);
|
||||||
const scale = baseScale * factors.scale;
|
return (
|
||||||
const composedTreatment = Math.max(
|
baseAlpha *
|
||||||
tuning.combinedAlphaFloor,
|
Math.max(
|
||||||
clamp(focusFactor, 0, 1) * factors.alpha,
|
tuning.combinedAlphaFloor,
|
||||||
|
clamp(focusFactor, 0, 1) * factors.alpha,
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const alpha = baseAlpha * composedTreatment;
|
|
||||||
let scaleWrites = 0;
|
|
||||||
let alphaWrites = 0;
|
|
||||||
if (
|
|
||||||
!Number.isFinite(billboard.scale) ||
|
|
||||||
Math.abs(billboard.scale - scale) > tuning.writeEpsilon
|
|
||||||
) {
|
|
||||||
billboard.scale = scale;
|
|
||||||
scaleWrites = 1;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!Number.isFinite(billboard.color?.alpha) ||
|
|
||||||
Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon
|
|
||||||
) {
|
|
||||||
billboard.color = baseColor.withAlpha(alpha);
|
|
||||||
alphaWrites = 1;
|
|
||||||
}
|
|
||||||
_treatmentResult.scale = scale;
|
|
||||||
_treatmentResult.alpha = alpha;
|
|
||||||
_treatmentResult.scaleWrites = scaleWrites;
|
|
||||||
_treatmentResult.alphaWrites = alphaWrites;
|
|
||||||
return _treatmentResult;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import assert from 'node:assert/strict';
|
|||||||
import {
|
import {
|
||||||
DEFAULT_AIRCRAFT_RECESSION_PARAMS,
|
DEFAULT_AIRCRAFT_RECESSION_PARAMS,
|
||||||
aircraftRecessionFactors,
|
aircraftRecessionFactors,
|
||||||
applyAircraftBillboardTreatment,
|
|
||||||
applyAircraftModelTreatment,
|
applyAircraftModelTreatment,
|
||||||
cameraLimbDistanceM,
|
cameraLimbDistanceM,
|
||||||
|
aircraftTreatmentAlpha,
|
||||||
} from './aircraftRecession.js';
|
} from './aircraftRecession.js';
|
||||||
|
|
||||||
const params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS };
|
const params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS };
|
||||||
@@ -66,72 +66,15 @@ test('globe-view transition eases to identity without a threshold pop', () => {
|
|||||||
assert.deepEqual(end, { scale: 1, alpha: 1, limbRatio: null });
|
assert.deepEqual(end, { scale: 1, alpha: 1, limbRatio: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('aircraft billboard wire writes scale and alpha only for the far treatment', () => {
|
|
||||||
const color = { withAlpha: (alpha) => ({ alpha }) };
|
|
||||||
const near = { scale: 1.2, color: { alpha: 0.8 } };
|
|
||||||
const nearResult = applyAircraftBillboardTreatment({
|
|
||||||
billboard: near,
|
|
||||||
baseScale: 1.2,
|
|
||||||
baseAlpha: 0.8,
|
|
||||||
baseColor: color,
|
|
||||||
focusFactor: 1,
|
|
||||||
cameraDistanceM: limb * 0.4,
|
|
||||||
cameraHeightM,
|
|
||||||
params,
|
|
||||||
});
|
|
||||||
assert.equal(nearResult.scaleWrites, 0);
|
|
||||||
assert.equal(nearResult.alphaWrites, 0);
|
|
||||||
|
|
||||||
const far = { scale: 1.2, color: { alpha: 0.8 } };
|
|
||||||
const farResult = applyAircraftBillboardTreatment({
|
|
||||||
billboard: far,
|
|
||||||
baseScale: 1.2,
|
|
||||||
baseAlpha: 0.8,
|
|
||||||
baseColor: color,
|
|
||||||
focusFactor: 0.5,
|
|
||||||
cameraDistanceM: limb,
|
|
||||||
cameraHeightM,
|
|
||||||
params,
|
|
||||||
});
|
|
||||||
assert.equal(farResult.scaleWrites, 1);
|
|
||||||
assert.equal(farResult.alphaWrites, 1);
|
|
||||||
assert.ok(Math.abs(far.scale - (1.2 * 0.45)) < 1e-12);
|
|
||||||
assert.ok(Math.abs(far.color.alpha - (0.8 * 0.20)) < 1e-12);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('combined focus and haze product is clamped at the composed alpha floor', () => {
|
test('combined focus and haze product is clamped at the composed alpha floor', () => {
|
||||||
const color = { withAlpha: (alpha) => ({ alpha }) };
|
const alpha = aircraftTreatmentAlpha({
|
||||||
const billboard = { scale: 0.45, color: { alpha: 1 } };
|
|
||||||
const result = applyAircraftBillboardTreatment({
|
|
||||||
billboard,
|
|
||||||
baseScale: 1,
|
|
||||||
baseAlpha: 1,
|
baseAlpha: 1,
|
||||||
baseColor: color,
|
|
||||||
focusFactor: 0.25,
|
focusFactor: 0.25,
|
||||||
cameraDistanceM: limb,
|
cameraDistanceM: limb,
|
||||||
cameraHeightM,
|
cameraHeightM,
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
assert.equal(result.alpha, 0.20);
|
assert.equal(alpha, 0.2);
|
||||||
assert.equal(billboard.color.alpha, 0.20);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('applyAircraftBillboardTreatment returns its documented module singleton', () => {
|
|
||||||
const color = { withAlpha: (alpha) => ({ alpha }) };
|
|
||||||
const billboard = { scale: 1, color: { alpha: 1 } };
|
|
||||||
const input = {
|
|
||||||
billboard,
|
|
||||||
baseAlpha: 1,
|
|
||||||
baseColor: color,
|
|
||||||
focusFactor: 1,
|
|
||||||
cameraDistanceM: 0,
|
|
||||||
cameraHeightM,
|
|
||||||
params,
|
|
||||||
};
|
|
||||||
const first = applyAircraftBillboardTreatment({ ...input, baseScale: 1 });
|
|
||||||
const second = applyAircraftBillboardTreatment({ ...input, baseScale: 0.5 });
|
|
||||||
assert.strictEqual(first, second);
|
|
||||||
assert.equal(first.scale, 0.5, 'the prior reference reflects the next call');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ambient model presentation receives composed alpha without changing blend semantics', () => {
|
test('ambient model presentation receives composed alpha without changing blend semantics', () => {
|
||||||
|
|||||||
+560
-296
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||||
import alprCamerasLayer, {
|
import alprCamerasLayer, {
|
||||||
createAlprCamerasLayer,
|
createAlprCamerasLayer,
|
||||||
configureAlprSource,
|
configureAlprSource,
|
||||||
@@ -31,6 +32,8 @@ import {
|
|||||||
isAlprSurveillanceType,
|
isAlprSurveillanceType,
|
||||||
normalizeAlprNode,
|
normalizeAlprNode,
|
||||||
} from './alprCameras.js';
|
} from './alprCameras.js';
|
||||||
|
import { readEntityRecord } from './entityRecords.js';
|
||||||
|
import { alprMarkers } from '../layers/alpr/markerState.js';
|
||||||
|
|
||||||
test('normalizeAlprNode: maps a full OSM node to a plain record', () => {
|
test('normalizeAlprNode: maps a full OSM node to a plain record', () => {
|
||||||
const record = normalizeAlprNode({
|
const record = normalizeAlprNode({
|
||||||
@@ -340,13 +343,21 @@ function cameraHarness(layer = alprCamerasLayer) {
|
|||||||
remove() {},
|
remove() {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
bindFakeCesiumCamera(viewer);
|
||||||
|
// The renderer's camera port relays moveEnd; the layer's own come on top.
|
||||||
|
const portMoveEndListeners = viewer.camera.moveEnd.numberOfListeners;
|
||||||
const setInput = Cesium.ScreenSpaceEventHandler.prototype.setInputAction;
|
const setInput = Cesium.ScreenSpaceEventHandler.prototype.setInputAction;
|
||||||
Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function (
|
Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function (
|
||||||
fn,
|
fn,
|
||||||
type,
|
type,
|
||||||
modifier,
|
modifier,
|
||||||
) {
|
) {
|
||||||
if (type === Cesium.ScreenSpaceEventType.LEFT_CLICK) click = fn;
|
// The unmodified click: the input port binds one per modifier.
|
||||||
|
if (
|
||||||
|
type === Cesium.ScreenSpaceEventType.LEFT_CLICK &&
|
||||||
|
modifier === undefined
|
||||||
|
)
|
||||||
|
click = fn;
|
||||||
return setInput.call(this, fn, type, modifier);
|
return setInput.call(this, fn, type, modifier);
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
@@ -357,6 +368,7 @@ function cameraHarness(layer = alprCamerasLayer) {
|
|||||||
layer.enable();
|
layer.enable();
|
||||||
return {
|
return {
|
||||||
viewer,
|
viewer,
|
||||||
|
portMoveEndListeners,
|
||||||
requests,
|
requests,
|
||||||
credits,
|
credits,
|
||||||
get source() {
|
get source() {
|
||||||
@@ -443,25 +455,25 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a
|
|||||||
const entities = h.source.entities.values;
|
const entities = h.source.entities.values;
|
||||||
assert.equal(alprCamerasLayer.name, 'ALPR Cameras');
|
assert.equal(alprCamerasLayer.name, 'ALPR Cameras');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
entities.map((entity) => entity.gevLabelModel.title),
|
entities.map((entity) => readEntityRecord(entity)?.labelModel.title),
|
||||||
['ALPR-0042', 'ALPR-0043', 'ALPR-0044'],
|
['ALPR-0042', 'ALPR-0043', 'ALPR-0044'],
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
new Set(entities.map((entity) => entity.billboard.image.getValue())).size,
|
new Set(entities.map((entity) => entity.billboard.image.getValue())).size,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
assert.deepEqual(entities[0].gevLabelModel.details, [
|
assert.deepEqual(readEntityRecord(entities[0])?.labelModel.details, [
|
||||||
'OSM MAPPED',
|
'OSM MAPPED',
|
||||||
'FLOCK SAFETY · CITY POLICE · FIXED',
|
'FLOCK SAFETY · CITY POLICE · FIXED',
|
||||||
'PUBLIC MAP DATA',
|
'PUBLIC MAP DATA',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(entities[1].gevLabelModel.details, [
|
assert.deepEqual(readEntityRecord(entities[1])?.labelModel.details, [
|
||||||
'OSM MAPPED',
|
'OSM MAPPED',
|
||||||
'MOTOROLA SOLUTIONS',
|
'MOTOROLA SOLUTIONS',
|
||||||
'PUBLIC MAP DATA',
|
'PUBLIC MAP DATA',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
entities[2].gevLabelModel.details,
|
readEntityRecord(entities[2])?.labelModel.details,
|
||||||
['OSM MAPPED', 'PUBLIC MAP DATA'],
|
['OSM MAPPED', 'PUBLIC MAP DATA'],
|
||||||
'unknown metadata is not guessed, but the source is always named',
|
'unknown metadata is not guessed, but the source is always named',
|
||||||
);
|
);
|
||||||
@@ -813,7 +825,7 @@ test('selected readout samples the rendered ground, not zero elevation or the di
|
|||||||
h.click('alpr:42');
|
h.click('alpr:42');
|
||||||
const entity = h.source.entities.getById('alpr:42');
|
const entity = h.source.entities.getById('alpr:42');
|
||||||
const anchor = Cesium.Cartographic.fromCartesian(
|
const anchor = Cesium.Cartographic.fromCartesian(
|
||||||
entity.gevDisplayPosition(),
|
readEntityRecord(entity)?.displayPosition(),
|
||||||
);
|
);
|
||||||
assert.ok(Math.abs(anchor.height - 187) < 0.001);
|
assert.ok(Math.abs(anchor.height - 187) < 0.001);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -821,7 +833,7 @@ test('selected readout samples the rendered ground, not zero elevation or the di
|
|||||||
);
|
);
|
||||||
for (let i = 0; i < 60; i++) {
|
for (let i = 0; i < 60; i++) {
|
||||||
h.viewer.scene.postRender.raiseEvent();
|
h.viewer.scene.postRender.raiseEvent();
|
||||||
entity.gevDisplayPosition();
|
readEntityRecord(entity)?.displayPosition();
|
||||||
}
|
}
|
||||||
assert.equal(samples, 1, 'no per-frame raycasts');
|
assert.equal(samples, 1, 'no per-frame raycasts');
|
||||||
h.advance(1000);
|
h.advance(1000);
|
||||||
@@ -921,11 +933,14 @@ test('two factories keep their requests, records and destruction independent', a
|
|||||||
assert.equal(b.source.entities.values[0].id, 'directory:99');
|
assert.equal(b.source.entities.values[0].id, 'directory:99');
|
||||||
assert.equal(second.source, 'Test camera directory');
|
assert.equal(second.source, 'Test camera directory');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
b.source.entities.values[0].gevLabelModel.details.includes(
|
readEntityRecord(
|
||||||
'Source: Test directory',
|
b.source.entities.values[0],
|
||||||
),
|
)?.labelModel.details.includes('Source: Test directory'),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
a.viewer.camera.moveEnd.numberOfListeners,
|
||||||
|
a.portMoveEndListeners,
|
||||||
);
|
);
|
||||||
assert.equal(a.viewer.camera.moveEnd.numberOfListeners, 0);
|
|
||||||
assert.equal(a.viewer.scene.postRender.numberOfListeners, 0);
|
assert.equal(a.viewer.scene.postRender.numberOfListeners, 0);
|
||||||
} finally {
|
} finally {
|
||||||
b.restore();
|
b.restore();
|
||||||
@@ -1444,12 +1459,12 @@ test('map-source changes reposition all ALPR entities and saved native appearanc
|
|||||||
await layer.update();
|
await layer.update();
|
||||||
const entities = [...h.source.entities.values];
|
const entities = [...h.source.entities.values];
|
||||||
const saved = entities[1];
|
const saved = entities[1];
|
||||||
saved.gevAlprNativeAppearance = {
|
alprMarkers.write(saved).nativeAppearance = {
|
||||||
position: saved.position,
|
position: saved.position,
|
||||||
heightReference: saved.billboard.heightReference,
|
heightReference: saved.billboard.heightReference,
|
||||||
};
|
};
|
||||||
saved.position = Cesium.Cartesian3.fromDegrees(-97.7431, 30.26721, 190);
|
saved.position = Cesium.Cartesian3.fromDegrees(-97.7431, 30.26721, 190);
|
||||||
saved.gevAlprPickPosition = saved.position;
|
alprMarkers.write(saved).pickPosition = saved.position;
|
||||||
for (const next of [150, 120, null, 180]) {
|
for (const next of [150, 120, null, 180]) {
|
||||||
floor = next;
|
floor = next;
|
||||||
// Includes a provider switch with the same globe.show value.
|
// Includes a provider switch with the same globe.show value.
|
||||||
@@ -1470,10 +1485,10 @@ test('map-source changes reposition all ALPR entities and saved native appearanc
|
|||||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||||
: Cesium.HeightReference.NONE,
|
: Cesium.HeightReference.NONE,
|
||||||
);
|
);
|
||||||
assert.equal(entity.gevAlprCanvasPosition, null);
|
assert.equal(alprMarkers.read(entity)?.canvasPosition, null);
|
||||||
}
|
}
|
||||||
assert.equal(
|
assert.equal(
|
||||||
saved.gevAlprNativeAppearance,
|
alprMarkers.read(saved)?.nativeAppearance,
|
||||||
null,
|
null,
|
||||||
'saved appearance restores at the new floor',
|
'saved appearance restores at the new floor',
|
||||||
);
|
);
|
||||||
|
|||||||
+36
-40
@@ -28,6 +28,9 @@ import { createImagerySplit } from '../ui/imagerySplit.js';
|
|||||||
import { formatDate, t, tn, msg, onLocaleChange } from '../i18n/index.js';
|
import { formatDate, t, tn, msg, onLocaleChange } from '../i18n/index.js';
|
||||||
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
|
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
|
||||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||||
|
import { heightsFor } from '../queries/heights.js';
|
||||||
|
import { cameraOf } from '../render/cesium/camera.js';
|
||||||
|
import { findCamera } from '../render/rendererOf.js';
|
||||||
|
|
||||||
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
|
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
|
||||||
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
|
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
|
||||||
@@ -122,20 +125,11 @@ const DEFAULT_EVENT_LOADER = async () => {
|
|||||||
const DEFAULT_IMAGERY_PROVIDER_FACTORY = (url, options) =>
|
const DEFAULT_IMAGERY_PROVIDER_FACTORY = (url, options) =>
|
||||||
Cesium.SingleTileImageryProvider.fromUrl(url, options);
|
Cesium.SingleTileImageryProvider.fromUrl(url, options);
|
||||||
const DEFAULT_TERRAIN_SAMPLER = async (viewer, observations) => {
|
const DEFAULT_TERRAIN_SAMPLER = async (viewer, observations) => {
|
||||||
if (
|
if (!viewer?.scene || !viewer.terrainProvider?.availability) return null;
|
||||||
!viewer?.terrainProvider ||
|
const heights = await heightsFor(viewer.scene).batch(observations, {
|
||||||
!viewer.terrainProvider.availability ||
|
against: 'terrain',
|
||||||
typeof Cesium.sampleTerrainMostDetailed !== 'function'
|
});
|
||||||
) {
|
return heights.map((height) => ({ height }));
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const cartographics = observations.map((observation) =>
|
|
||||||
Cesium.Cartographic.fromDegrees(observation.lon, observation.lat),
|
|
||||||
);
|
|
||||||
return Cesium.sampleTerrainMostDetailed(
|
|
||||||
viewer.terrainProvider,
|
|
||||||
cartographics,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
const DEFAULT_MEDIA_LOADER = async (url, { signal } = {}) => {
|
const DEFAULT_MEDIA_LOADER = async (url, { signal } = {}) => {
|
||||||
if (typeof createImageBitmap !== 'function') {
|
if (typeof createImageBitmap !== 'function') {
|
||||||
@@ -896,20 +890,24 @@ function focusView(viewer, viewpoint) {
|
|||||||
viewpoint.lat,
|
viewpoint.lat,
|
||||||
viewpoint.targetElevationM,
|
viewpoint.targetElevationM,
|
||||||
);
|
);
|
||||||
const offset = new Cesium.HeadingPitchRange(
|
const offset = {
|
||||||
Cesium.Math.toRadians(viewpoint.headingDeg),
|
heading: Cesium.Math.toRadians(viewpoint.headingDeg),
|
||||||
Cesium.Math.toRadians(viewpoint.pitchDeg),
|
pitch: Cesium.Math.toRadians(viewpoint.pitchDeg),
|
||||||
viewpoint.rangeM,
|
range: viewpoint.rangeM,
|
||||||
);
|
};
|
||||||
viewer.camera.cancelFlight?.();
|
const camera = cameraOf(viewer);
|
||||||
viewer.camera.flyToBoundingSphere(new Cesium.BoundingSphere(target, 80), {
|
camera.cancelFlight();
|
||||||
offset,
|
camera.flyToBounds(
|
||||||
duration: 2.2,
|
{ center: target, radius: 80 },
|
||||||
complete: () => {
|
{
|
||||||
viewer.camera.lookAt(target, offset);
|
offset,
|
||||||
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
durationS: 2.2,
|
||||||
|
onComplete: () => {
|
||||||
|
camera.lookAt(target, offset);
|
||||||
|
camera.release();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setElementProperty(element, property, value) {
|
function setElementProperty(element, property, value) {
|
||||||
@@ -1053,7 +1051,7 @@ export function createBhoteKoshiEventLayer({
|
|||||||
let _cinematicActive = false;
|
let _cinematicActive = false;
|
||||||
let _cinematicReplayArmed = false;
|
let _cinematicReplayArmed = false;
|
||||||
let _cinematicPose = { target: new Cesium.Cartesian3() };
|
let _cinematicPose = { target: new Cesium.Cartesian3() };
|
||||||
let _cinematicOffset = new Cesium.HeadingPitchRange();
|
const _cinematicOffset = { heading: 0, pitch: 0, range: 0 };
|
||||||
let _lastCinematicProgress = null;
|
let _lastCinematicProgress = null;
|
||||||
let _cameraInputTarget = null;
|
let _cameraInputTarget = null;
|
||||||
let _evidenceClickTarget = null;
|
let _evidenceClickTarget = null;
|
||||||
@@ -1197,7 +1195,8 @@ export function createBhoteKoshiEventLayer({
|
|||||||
}
|
}
|
||||||
throwIfEnableCancelled(signal, _enabled);
|
throwIfEnableCancelled(signal, _enabled);
|
||||||
return observations.map((observation, index) => {
|
return observations.map((observation, index) => {
|
||||||
const sampledHeight = Number(sampled?.[index]?.height);
|
// A miss is null or undefined, never sea level.
|
||||||
|
const sampledHeight = Number(sampled?.[index]?.height ?? Number.NaN);
|
||||||
const fallbackHeight = Number(observation.fallbackElevationM) || 0;
|
const fallbackHeight = Number(observation.fallbackElevationM) || 0;
|
||||||
return Cesium.Cartesian3.fromDegrees(
|
return Cesium.Cartesian3.fromDegrees(
|
||||||
observation.lon,
|
observation.lon,
|
||||||
@@ -1573,14 +1572,12 @@ export function createBhoteKoshiEventLayer({
|
|||||||
// a single high sample can make the front dive backward in screen space.
|
// a single high sample can make the front dive backward in screen space.
|
||||||
if (_sceneControls.evidencePathElevation !== 'source') {
|
if (_sceneControls.evidencePathElevation !== 'source') {
|
||||||
// Sample once while preparing the shot, never from an animation frame.
|
// Sample once while preparing the shot, never from an animation frame.
|
||||||
try {
|
const heights = _viewer?.scene ? heightsFor(_viewer.scene) : null;
|
||||||
height = _viewer?.scene?.sampleHeight?.(
|
height = heights?.now(position, {
|
||||||
position,
|
exclude: [_floodCore, _floodHalo, _surgePoint].filter(Boolean),
|
||||||
[_floodCore, _floodHalo, _surgePoint].filter(Boolean),
|
});
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
if (!Number.isFinite(height))
|
if (!Number.isFinite(height))
|
||||||
height = _viewer?.scene?.globe?.getHeight?.(position);
|
height = heights?.now(position, { against: 'terrain' });
|
||||||
}
|
}
|
||||||
if (!Number.isFinite(height)) height = point.fallbackElevationM;
|
if (!Number.isFinite(height)) height = point.fallbackElevationM;
|
||||||
return Cesium.Cartesian3.fromDegrees(point.lon, point.lat, height + 8);
|
return Cesium.Cartesian3.fromDegrees(point.lon, point.lat, height + 8);
|
||||||
@@ -2635,7 +2632,7 @@ export function createBhoteKoshiEventLayer({
|
|||||||
_cinematicOffset.heading = Cesium.Math.toRadians(pose.headingDeg);
|
_cinematicOffset.heading = Cesium.Math.toRadians(pose.headingDeg);
|
||||||
_cinematicOffset.pitch = Cesium.Math.toRadians(pose.pitchDeg);
|
_cinematicOffset.pitch = Cesium.Math.toRadians(pose.pitchDeg);
|
||||||
_cinematicOffset.range = pose.rangeM;
|
_cinematicOffset.range = pose.rangeM;
|
||||||
_viewer.camera.lookAt(pose.target, _cinematicOffset);
|
findCamera(_viewer)?.lookAt(pose.target, _cinematicOffset);
|
||||||
_lastCinematicProgress = _progress;
|
_lastCinematicProgress = _progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2705,7 +2702,7 @@ export function createBhoteKoshiEventLayer({
|
|||||||
_cinematicActive = true;
|
_cinematicActive = true;
|
||||||
_lastCinematicProgress = null;
|
_lastCinematicProgress = null;
|
||||||
announceNavigationAuthority('bhote-koshi-cinematic');
|
announceNavigationAuthority('bhote-koshi-cinematic');
|
||||||
_viewer?.camera?.cancelFlight?.();
|
findCamera(_viewer)?.cancelFlight();
|
||||||
}
|
}
|
||||||
syncCinematicButton();
|
syncCinematicButton();
|
||||||
return true;
|
return true;
|
||||||
@@ -2722,8 +2719,7 @@ export function createBhoteKoshiEventLayer({
|
|||||||
}
|
}
|
||||||
_cinematicActive = false;
|
_cinematicActive = false;
|
||||||
_lastCinematicProgress = null;
|
_lastCinematicProgress = null;
|
||||||
if (releaseCamera)
|
if (releaseCamera) findCamera(_viewer)?.release();
|
||||||
_viewer?.camera?.lookAtTransform?.(Cesium.Matrix4.IDENTITY);
|
|
||||||
syncCinematicButton();
|
syncCinematicButton();
|
||||||
renderHost.request('bhote-koshi-cinematic-stop');
|
renderHost.request('bhote-koshi-cinematic-stop');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
@@ -239,7 +240,7 @@ function eventViewer(splitPosition = 0.37) {
|
|||||||
const canvas = new FakeElement('canvas');
|
const canvas = new FakeElement('canvas');
|
||||||
const cameraLooks = [];
|
const cameraLooks = [];
|
||||||
const cameraTransforms = [];
|
const cameraTransforms = [];
|
||||||
return {
|
return bindFakeCesiumCamera({
|
||||||
scene: { splitPosition, canvas },
|
scene: { splitPosition, canvas },
|
||||||
camera: {
|
camera: {
|
||||||
cancelFlight() {},
|
cancelFlight() {},
|
||||||
@@ -272,7 +273,7 @@ function eventViewer(splitPosition = 0.37) {
|
|||||||
cameraLooks,
|
cameraLooks,
|
||||||
cameraTransforms,
|
cameraTransforms,
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('public lifecycle restores Nepal scene ownership without forwarding origin to enable', async () => {
|
test('public lifecycle restores Nepal scene ownership without forwarding origin to enable', async () => {
|
||||||
@@ -2992,3 +2993,43 @@ test('Bhote Koshi corridor remains ordered downstream and inside the imagery rec
|
|||||||
priorChainage = point.chainageM;
|
priorChainage = point.chainageM;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an evidence point whose terrain sample misses sits at its fallback elevation, not sea level', async () => {
|
||||||
|
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
installEventDom();
|
||||||
|
globalThis.window = { setTimeout: () => 71, clearTimeout() {} };
|
||||||
|
const viewer = eventViewer();
|
||||||
|
const publications = [];
|
||||||
|
const layer = createBhoteKoshiEventLayer({
|
||||||
|
eventLoader: async () => event,
|
||||||
|
imageryProviderFactory: async (url) => ({ url }),
|
||||||
|
terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: null })),
|
||||||
|
mediaLoader: async () => {
|
||||||
|
const error = new Error('fixture media intentionally unavailable');
|
||||||
|
error.name = 'AbortError';
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
overlayHost: { setEntries(_source, entries) { publications.push(entries); }, setVisible() {}, clearSource() {} },
|
||||||
|
renderHost: { request() {}, hold() {}, release() {} },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
layer.setParams({
|
||||||
|
presentation: 'scene-beat',
|
||||||
|
sceneSurface: 'evidence-beat',
|
||||||
|
beatId: 'gyirong-border-gate',
|
||||||
|
}, { origin: 'scene' });
|
||||||
|
await layer.enable(viewer, { origin: 'scene' });
|
||||||
|
const card = publications.at(-1).find((entry) => entry.id === 'evidence-card-gyirong-border-gate');
|
||||||
|
const fallback = event.evidenceSpine.find(({ id }) => id === 'gyirong-border-gate').fallbackElevationM;
|
||||||
|
assert.ok(fallback > 100, 'the fixture has a real fallback elevation');
|
||||||
|
const height = Cesium.Cartographic.fromCartesian(card.position()).height;
|
||||||
|
assert.ok(Math.abs(height - (fallback + 18)) < 1, `card at ${height} m`);
|
||||||
|
await layer.disable();
|
||||||
|
} finally {
|
||||||
|
await layer.destroy();
|
||||||
|
globalThis.document = originalDocument;
|
||||||
|
globalThis.window = originalWindow;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
setOverlayEntries,
|
setOverlayEntries,
|
||||||
} from '../overlays/worldOverlay.js';
|
} from '../overlays/worldOverlay.js';
|
||||||
import { governorRequestRender } from '../renderGovernor.js';
|
import { governorRequestRender } from '../renderGovernor.js';
|
||||||
|
import { cameraOf } from '../render/cesium/camera.js';
|
||||||
import {
|
import {
|
||||||
BHOTE_KOSHI_LOCATOR_CREDIT,
|
BHOTE_KOSHI_LOCATOR_CREDIT,
|
||||||
registerDynamicCredit,
|
registerDynamicCredit,
|
||||||
@@ -620,6 +621,7 @@ export function createBhoteKoshiLocatorLayer({
|
|||||||
requestRender('bhote-koshi-trigger-record-reveal');
|
requestRender('bhote-koshi-trigger-record-reveal');
|
||||||
const camera = _viewer?.camera;
|
const camera = _viewer?.camera;
|
||||||
if (!camera?.flyTo) return;
|
if (!camera?.flyTo) return;
|
||||||
|
const port = cameraOf(_viewer);
|
||||||
const generation = _generation;
|
const generation = _generation;
|
||||||
_triggerApproachInFlight = true;
|
_triggerApproachInFlight = true;
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
@@ -627,50 +629,54 @@ export function createBhoteKoshiLocatorLayer({
|
|||||||
attempt.phase = 'finished';
|
attempt.phase = 'finished';
|
||||||
_triggerApproachInFlight = false;
|
_triggerApproachInFlight = false;
|
||||||
};
|
};
|
||||||
camera.flyTo({
|
port.flyTo(
|
||||||
destination: Cesium.Cartesian3.fromDegrees(
|
{
|
||||||
TRIGGER_APPROACH_CAMERA.lon,
|
lon: TRIGGER_APPROACH_CAMERA.lon,
|
||||||
TRIGGER_APPROACH_CAMERA.lat,
|
lat: TRIGGER_APPROACH_CAMERA.lat,
|
||||||
TRIGGER_APPROACH_CAMERA.alt,
|
height: TRIGGER_APPROACH_CAMERA.alt,
|
||||||
),
|
|
||||||
orientation: {
|
|
||||||
heading: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.heading),
|
|
||||||
pitch: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.pitch),
|
|
||||||
roll: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.roll),
|
|
||||||
},
|
},
|
||||||
duration: TRIGGER_APPROACH_DURATION_SEC,
|
{
|
||||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
orientation: {
|
||||||
complete: () => {
|
heading: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.heading),
|
||||||
if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying')
|
pitch: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.pitch),
|
||||||
return;
|
roll: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.roll),
|
||||||
if (
|
},
|
||||||
!_enabled ||
|
durationS: TRIGGER_APPROACH_DURATION_SEC,
|
||||||
generation !== _generation ||
|
easing: 'cubicInOut',
|
||||||
_presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION
|
onComplete: () => {
|
||||||
) {
|
if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying')
|
||||||
finish();
|
return;
|
||||||
return;
|
if (
|
||||||
}
|
!_enabled ||
|
||||||
attempt.phase = 'orbiting';
|
generation !== _generation ||
|
||||||
camera.flyTo({
|
_presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION
|
||||||
destination: Cesium.Cartesian3.fromDegrees(
|
) {
|
||||||
TRIGGER_ORBIT_CAMERA.lon,
|
finish();
|
||||||
TRIGGER_ORBIT_CAMERA.lat,
|
return;
|
||||||
TRIGGER_ORBIT_CAMERA.alt,
|
}
|
||||||
),
|
attempt.phase = 'orbiting';
|
||||||
orientation: {
|
port.flyTo(
|
||||||
heading: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.heading),
|
{
|
||||||
pitch: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.pitch),
|
lon: TRIGGER_ORBIT_CAMERA.lon,
|
||||||
roll: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.roll),
|
lat: TRIGGER_ORBIT_CAMERA.lat,
|
||||||
},
|
height: TRIGGER_ORBIT_CAMERA.alt,
|
||||||
duration: TRIGGER_ORBIT_DURATION_SEC,
|
},
|
||||||
easingFunction: Cesium.EasingFunction.SINUSOIDAL_IN_OUT,
|
{
|
||||||
complete: finish,
|
orientation: {
|
||||||
cancel: finish,
|
heading: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.heading),
|
||||||
});
|
pitch: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.pitch),
|
||||||
|
roll: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.roll),
|
||||||
|
},
|
||||||
|
durationS: TRIGGER_ORBIT_DURATION_SEC,
|
||||||
|
easing: 'sineInOut',
|
||||||
|
onComplete: finish,
|
||||||
|
onCancel: finish,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onCancel: finish,
|
||||||
},
|
},
|
||||||
cancel: finish,
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function armTriggerApproach() {
|
function armTriggerApproach() {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
@@ -647,6 +648,7 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
|
|||||||
},
|
},
|
||||||
cancelFlight() {},
|
cancelFlight() {},
|
||||||
};
|
};
|
||||||
|
bindFakeCesiumCamera(viewer);
|
||||||
const layer = createBhoteKoshiLocatorLayer({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
boundaryResolver: async () => null,
|
boundaryResolver: async () => null,
|
||||||
requestRender() {},
|
requestRender() {},
|
||||||
@@ -703,6 +705,9 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
|||||||
},
|
},
|
||||||
cancelFlight() {},
|
cancelFlight() {},
|
||||||
};
|
};
|
||||||
|
bindFakeCesiumCamera(viewer);
|
||||||
|
// The renderer's camera port relays these; the layer's own come on top.
|
||||||
|
const portListeners = [moveStart, moveEnd].map((e) => e.numberOfListeners);
|
||||||
const layer = createBhoteKoshiLocatorLayer({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
boundaryResolver: async () => null,
|
boundaryResolver: async () => null,
|
||||||
overlayHost: overlayHostFixture(),
|
overlayHost: overlayHostFixture(),
|
||||||
@@ -745,8 +750,10 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
|||||||
const oldFlights = [...flights];
|
const oldFlights = [...flights];
|
||||||
SceneDirector.prototype.stopScene.call(director);
|
SceneDirector.prototype.stopScene.call(director);
|
||||||
assert.equal(timers.size, 0, 'Stop cancels the actual delayed work');
|
assert.equal(timers.size, 0, 'Stop cancels the actual delayed work');
|
||||||
assert.equal(moveStart.numberOfListeners, 0);
|
assert.deepEqual(
|
||||||
assert.equal(moveEnd.numberOfListeners, 0);
|
[moveStart, moveEnd].map((e) => e.numberOfListeners),
|
||||||
|
portListeners,
|
||||||
|
);
|
||||||
callbacks[0]();
|
callbacks[0]();
|
||||||
moveEnd.raiseEvent();
|
moveEnd.raiseEvent();
|
||||||
for (const flight of oldFlights) flight.complete();
|
for (const flight of oldFlights) flight.complete();
|
||||||
|
|||||||
Loaded 100 of 549 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user