mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 03:44:00 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc61a39738 | ||
|
|
9ab54675ba | ||
|
|
3cecc8efbe | ||
|
|
8eab6a76a8 | ||
|
|
4fc9722356 | ||
|
|
287c881c17 | ||
|
|
497016b084 | ||
|
|
724109ee17 | ||
|
|
09d833ed96 | ||
|
|
0d96fe6acd | ||
|
|
32e08f5043 | ||
|
|
056b3b58c6 | ||
|
|
d04e304502 | ||
|
|
e7973e81cf | ||
|
|
0aa54dfa97 | ||
|
|
48093108b1 | ||
|
|
9faec3028e | ||
|
|
3534398771 | ||
|
|
690a6637e2 | ||
|
|
434fc9602d | ||
|
|
32ec6ed436 | ||
|
|
0c4da3cdb2 |
No files matched your search
@@ -9,8 +9,6 @@ coverage
|
||||
pinokio/ENVIRONMENT
|
||||
pinokio/.installed
|
||||
src/data/local_data/
|
||||
# Written by scripts/record-camera-goldens.mjs and compared byte for byte.
|
||||
src/render/conformance/goldens/
|
||||
*.geojson
|
||||
*.geojsonl
|
||||
|
||||
|
||||
@@ -98,8 +98,6 @@ 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 |
|
||||
| **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)" |
|
||||
| **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 |
|
||||
|
||||
### Local receiver input (not fetched from a service)
|
||||
@@ -120,7 +118,6 @@ 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).
|
||||
|
||||
- **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.
|
||||
- **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>
|
||||
|
||||
@@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
**Local setup needs no account or API keys.** The app opens with
|
||||
**Start without an account or API keys.** Every path opens the same app with
|
||||
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
|
||||
unreachable. Flights, military traffic, satellites, earthquakes, public
|
||||
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'`.
|
||||
|
||||
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.
|
||||
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)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -236,11 +236,7 @@ the container.
|
||||
|
||||
### Path 4 — GitHub Codespaces (cloud, no local install)
|
||||
|
||||
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
|
||||
Runs the same 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
|
||||
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
|
||||
@@ -269,13 +265,14 @@ ignored on purpose, so pass that exact name.
|
||||
|
||||
⚠️ **A codespace is reachable from the internet.** Treat it as the
|
||||
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
|
||||
Settings is unavailable on the forwarded URL. Never commit your workspace
|
||||
`.env`. [SECURITY.md](SECURITY.md) has the threat model.
|
||||
Settings does not open on the forwarded URL, so put keys in the workspace
|
||||
`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the
|
||||
threat model.
|
||||
|
||||
### Then power it up — in the app, not in a file
|
||||
|
||||
For a local setup, keys are upgrades, not prerequisites. When you want one,
|
||||
click the **POWER UP** chip in the bottom-right corner: Provider Settings lists every supported key,
|
||||
Keys are upgrades, not prerequisites. When you want one, 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
|
||||
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`
|
||||
|
||||
+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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
- 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).
|
||||
@@ -1,13 +0,0 @@
|
||||
- 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).
|
||||
@@ -1,5 +0,0 @@
|
||||
- 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).
|
||||
@@ -1,3 +0,0 @@
|
||||
- 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).
|
||||
@@ -1,5 +0,0 @@
|
||||
- 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).
|
||||
@@ -1,3 +0,0 @@
|
||||
- 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,5 @@
|
||||
- One world clock now drives everything with a time of its own: the sky,
|
||||
the HUD clock, satellites, launches and weather. A timeline along the
|
||||
bottom of the screen, shown while one of those layers is on, plays, pauses
|
||||
and scrubs it, with a tick for each launch or frame and a way back to
|
||||
live. Share links made away from live carry the time (samehkhamis, #1512).
|
||||
@@ -0,0 +1,7 @@
|
||||
- The timeline brings launches up as the time passes them, offers play
|
||||
speeds from real time to a week a second, and plays smoothly, holding only
|
||||
when the next weather frame has yet to load, with a spinner by the time
|
||||
while it waits. The time rides in a bubble over the handle. The track zooms
|
||||
with a scroll or a pinch, drags along, and goes to the time under a tap, on
|
||||
a phone as on a desktop. Zooming out on the globe stops while the globe is
|
||||
still in view instead of leaving it a speck (samehkhamis, #1513).
|
||||
@@ -1,114 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,230 +0,0 @@
|
||||
/**
|
||||
* 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,100 +81,6 @@ constructed component objects; the component instances themselves remain mutable
|
||||
`ready` means all constructors have returned. Background feed activity and the
|
||||
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
|
||||
|
||||
`src/main.js` reads the existing browser configuration and starts
|
||||
|
||||
@@ -33,13 +33,12 @@ 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/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/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 |
|
||||
| `server/providers/` | Node route factories, process-scoped provider caches and shutdown cleanup |
|
||||
| `server/standalone/` | Environment, local settings writes and server composition |
|
||||
|
||||
See [application construction](APPLICATION.md) and the
|
||||
[infrastructure contract](INFRASTRUCTURE-LAYERS.md) for construction interfaces, and the [renderer contract](RENDERER-CONTRACT.md) for what a renderer provides.
|
||||
[infrastructure contract](INFRASTRUCTURE-LAYERS.md) for construction interfaces.
|
||||
|
||||
## Import direction gates
|
||||
|
||||
|
||||
+68
-119
@@ -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
|
||||
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
|
||||
longitude and latitude, or a readsb row with a hex but no `lat`/`lon`, is a
|
||||
contact without a position, not a malformed row: it leaves the snapshot complete
|
||||
and is listed in `retainIds`, so civil and military flights retain that aircraft
|
||||
under the same five-minute bound, unless it is likely landed, while other absent
|
||||
aircraft fade and leave on the missed-poll limits. A nonempty snapshot with no
|
||||
positioned rows is still malformed. Unknown snapshot times remain unknown in stats. An invalid nonempty
|
||||
longitude and latitude is a contact without a position, not a malformed row: it
|
||||
leaves the snapshot complete and is listed in `retainIds`, so civil flights
|
||||
retain that aircraft under the same five-minute bound, unless it is likely
|
||||
landed, while other absent aircraft fade and leave on the missed-poll limits. A
|
||||
nonempty snapshot with no positioned rows is still malformed. Military readsb
|
||||
admission is unchanged. Unknown snapshot times remain unknown in stats. An invalid nonempty
|
||||
snapshot retains the previous display. Empty vessel refreshes retain the existing
|
||||
first-connect grace and warm-data behavior. Known source failures have bounded
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
and a Denver coordinate and requires at least 60 m clearance.
|
||||
|
||||
## Layer panel ownership
|
||||
@@ -1473,14 +1473,11 @@ remain unchanged.
|
||||
|
||||
## Visual effects ownership
|
||||
|
||||
VisualEffects keeps the records of the styles, their crossfades, bloom and
|
||||
sharpen, and drives them through the renderer's effects port: it defines each
|
||||
preset there as a style, and the renderer runs the passes and animates the
|
||||
animated ones. Display supplies actions and renders settings; effect execution
|
||||
has no DOM dependency. Existing presets and the 500 ms transition remain
|
||||
unchanged. Stopping the controller revokes animation before asynchronous UI
|
||||
teardown; final destruction removes its styles and turns bloom and sharpen off,
|
||||
and the renderer restores the scene's bloom configuration when it goes.
|
||||
VisualEffects owns style-stage creation, crossfades, animation scheduling, bloom
|
||||
and sharpen. Display supplies actions and renders settings; effect execution has
|
||||
no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
|
||||
Stopping the controller revokes animation before asynchronous UI teardown; final
|
||||
destruction removes its stages and restores the borrowed bloom configuration.
|
||||
Snow is no longer a visual preset. A share link that names it, in either the
|
||||
long or compact form, opens on Normal and drops the Snow shader parameters. A
|
||||
view passed to embed mode or `show_in_gods_eye_view`, and a saved scene shot,
|
||||
@@ -2399,9 +2396,9 @@ which embeds the MAP_KEY.
|
||||
> so a turned-off layer's entities stayed reachable (Data Centers kept 116
|
||||
> MiB). `src/sceneCommandRelease.js`, installed in `src/app/tools.js`,
|
||||
> requests one frame through `governorRequestRender` after every layer
|
||||
> disable and then trims those arrays. It reads private Cesium fields through
|
||||
> `src/render/cesium/internals.js`; `src/sceneCommandRelease.test.mjs` pins
|
||||
> them and fails if Cesium renames them or changes which passes it truncates.
|
||||
> disable and then trims those arrays. It reads private Cesium fields;
|
||||
> `src/sceneCommandRelease.test.mjs` pins 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
|
||||
> governor (`src/renderGovernor.js` — hold/release from every per-frame
|
||||
@@ -3095,10 +3092,9 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> 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.
|
||||
> Its Radio popover exposes compact power,
|
||||
> transport, station, and volume controls. Cockpit Previous/Next changes the
|
||||
> selection without starting the map-view station flights that compete with
|
||||
> the first-person camera; like the Context Previous/Next buttons, it plays
|
||||
> (with broadcaster fallback) only while a stream is already active. These
|
||||
> transport, station, and volume controls. Cockpit Previous/Next preserves
|
||||
> selection, autoplay, and broadcaster fallback audio without starting the
|
||||
> map-view station flights that compete with the first-person camera. These
|
||||
> are a Cockpit-only accordion:
|
||||
> 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
|
||||
@@ -3261,25 +3257,16 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> outlasts its retries, the proxy pauses upstream calls for the `Retry-After`
|
||||
> (60 seconds when none, at most 10 minutes). A request whose points are all
|
||||
> cached, fresh or stale, is still served; a request with any uncached point
|
||||
> gets 502, 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
|
||||
> gets 502 and the page uses its geoid fallback for that chunk. Each request
|
||||
> that was waiting on the upstream when the 429 came back logs one warning;
|
||||
> requests refused during the pause do not log. Client geoid
|
||||
> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the
|
||||
> first later successful fetch. Smaller chunks reduce timeout risk; complete
|
||||
> camera batches still wait for their sequential requests, and unresolved
|
||||
> placement continues to use the existing prior until real heights arrive.
|
||||
> - **Overpass cache admission:** `/api/overpass` refuses cross-site POSTs
|
||||
> through the keyless same-site gate (reverse-proxy forwarding headers are
|
||||
> 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.
|
||||
> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded
|
||||
> POSTs through the shared local-request gate, then limits requests to 300/min
|
||||
> per socket peer and 900/min globally before reading or validating bodies.
|
||||
> 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
|
||||
> before invoking its separate local 90/min upstream limiter. Cache and
|
||||
@@ -3466,14 +3453,8 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> celestial ring, annotations SVG, missions frame tick, and traffic).
|
||||
> - **World-label accounting:** All intended label/card migrations use the
|
||||
> shared host. The former native `LabelGraphics` exception for cable reference
|
||||
> labels was retired on 2026-08-18; cable text may render over tile geometry.
|
||||
> 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
|
||||
> 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
|
||||
> compass, annotation SVG callouts, and host-owned detection isolation target
|
||||
> are explicit exceptions; the awareness ring and cockpit contact pips are
|
||||
> non-text out-of-scope surfaces. Annotation callout text, geometry, leaders,
|
||||
@@ -3522,7 +3503,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> ambient-label host source. Its getter reads the already-propagated point
|
||||
> cache, preserving the 1 Hz fleet epoch and eliminating the former second ISS
|
||||
> propagation. Satellite tracking continues to publish exactly one protected
|
||||
> tracked-lane card through its entity record's `labelModel` and `_trackedDisplayCached`; the
|
||||
> tracked-lane card through `gevLabelModel` and `_trackedDisplayCached`; the
|
||||
> tracked entity is point-only. Tracking ISS suppresses the ambient entry and
|
||||
> untracking restores it, preventing duplicate ISS text. Disable, orbit-text
|
||||
> preference changes, catalog rebuild, and destroy clear/hide the source.
|
||||
@@ -3537,7 +3518,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> - **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
|
||||
> retains callsign/registration fallback, flight-level/altitude, speed, stale
|
||||
> state, airline/type, and plausible-route formatting in the entity record's `labelModel`; the
|
||||
> state, airline/type, and plausible-route formatting in `gevLabelModel`; the
|
||||
> one protected tracked-lane host card renders that complete model. Its
|
||||
> position getter reads only `_trackedDisplayCached`, the same authoritative
|
||||
> Cartesian already consumed by the tracked visual and camera, and never
|
||||
@@ -3644,11 +3625,8 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> - **Tracked-readout ownership:** `trackedReadout.js` is now a presentation-model
|
||||
> bridge only; it owns no canvas, projection, post-render listener, layout,
|
||||
> keyhole fade, or paint path. Civilian flights, military flights, satellites,
|
||||
> and mapped installations write explicit `labelModel` objects and
|
||||
> `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`).
|
||||
> and mapped installations write explicit `gevLabelModel` objects and expose
|
||||
> `gevDisplayPosition` getters backed by their layer-owned frame/display cache.
|
||||
> Selected AIS vessels continue using the vessel source's protected selected
|
||||
> 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;
|
||||
@@ -4012,33 +3990,31 @@ 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
|
||||
its criteria cannot be silently ignored.
|
||||
|
||||
| Layer | Source | File | Proxy | Update Interval |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| Datacenters ▣ | 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 |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| Layer | Source | File | Proxy | Update Interval |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| Datacenters ▣ | 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 |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
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;
|
||||
@@ -4406,11 +4382,11 @@ the same navigation authority as every other camera destination.
|
||||
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
|
||||
can never free the claim its replacement holds. The claim is held until
|
||||
the click that uses it has finished being dispatched: the input dispatcher
|
||||
checks the claim once per click, before any subscriber runs, but DOM canvas
|
||||
listeners (the evidence cards) still see the same browser event afterwards, so
|
||||
a claim dropped the instant the endpoint is placed would hand them that click.
|
||||
It is returned on the next tick after placement, and immediately on cancel, Escape, CLEAR, disable
|
||||
the click that uses it has finished being dispatched — every layer binds its
|
||||
own handler to the same canvas and they all run inside one browser event, so a
|
||||
claim dropped the instant the endpoint is placed would hand the rest of that
|
||||
same click to the ambient handlers bound after this one. 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
|
||||
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
|
||||
@@ -4760,12 +4736,10 @@ accepted snapshot, and feed telemetry before re-initialization; monotonic
|
||||
ownership tokens plus the session boundary keep late callbacks from a retired
|
||||
session inert.
|
||||
A tuner drag resolves previews against the single accepted snapshot captured at
|
||||
pointer-down, even while a newer catalog is admitted. Like Previous/Next,
|
||||
release starts playback only if a stream was active when the drag began;
|
||||
otherwise it just selects the station. It commits only when the current same-ID
|
||||
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
|
||||
pointer-down, even while a newer catalog is admitted. Release starts playback
|
||||
only when the current same-ID 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
|
||||
populate or begin the tuner.
|
||||
|
||||
@@ -4942,9 +4916,7 @@ 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
|
||||
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
|
||||
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.
|
||||
newer-response or user-origin Stop still cancels stale handoff work.
|
||||
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
|
||||
controls and generic layer-visibility commands both forward cancellation.
|
||||
@@ -5433,11 +5405,8 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
### 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).
|
||||
- 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.
|
||||
- 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.
|
||||
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
|
||||
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
|
||||
- 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
|
||||
@@ -5640,9 +5609,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.
|
||||
- **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which.
|
||||
- Drawn-area measures round before choosing m², ha or km², so 9,999.6 m² displays as `1.0 ha`, not `10000 m²`, and 999,960 m² as `1.00 km²`, not `100.0 ha`.
|
||||
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`), and 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.
|
||||
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection.
|
||||
- **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing.
|
||||
- **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).
|
||||
- **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.
|
||||
- **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`.
|
||||
- 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`.
|
||||
@@ -5722,7 +5691,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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### TR-3B conversion Easter egg (August 2026)
|
||||
|
||||
@@ -5997,26 +5966,6 @@ easier to meet (detection is now on more often), but does not create it.
|
||||
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).
|
||||
|
||||
## 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
|
||||
|
||||
- **Earthquake discs are STATIC geometry.** Every quake is a `CLAMP_TO_GROUND`
|
||||
|
||||
@@ -95,11 +95,6 @@ registry of source functions. Each receives `{ path, signal, maxBytes }` and
|
||||
returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the
|
||||
signal, bound reads and return the real MIME type. They remain application code,
|
||||
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
|
||||
`{ pack, asset, anchors, signal }` and returns a resource with an idempotent
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
# 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.
|
||||
@@ -119,6 +119,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
|
||||
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
|
||||
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
|
||||
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
|
||||
| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z |
|
||||
|
||||
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
|
||||
so v1 never writes it; a later view kind writes its code.
|
||||
@@ -132,6 +133,10 @@ style also extends the pinned style-defaults table.
|
||||
`hudVariant` code 1 (`operator`) is retired: the app no longer offers that
|
||||
layout and restores it as `tactical`. The code stays reserved.
|
||||
|
||||
Field 28 is the world time the link was made at, written only when it was away
|
||||
from live. A link without it opens live. Unlike field 8 it may lie ahead of the
|
||||
link's creation (a forecast moment), and it opens paused at that time.
|
||||
|
||||
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
|
||||
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||
names a glyph this list is missing, because a missing glyph does not draw
|
||||
a box — the ligature never forms and the element renders the literal word. -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,play_arrow,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||
|
||||
Generated
-15
@@ -32,7 +32,6 @@
|
||||
"prettier": "3.9.6",
|
||||
"puppeteer": "^25.16.0",
|
||||
"sharp": "^0.35.4",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^6.4.4",
|
||||
"vite-plugin-cesium": "^1.2.23",
|
||||
"ws": "^8.21.0"
|
||||
@@ -3546,20 +3545,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||
|
||||
+2
-9
@@ -79,7 +79,6 @@
|
||||
"prettier": "3.9.6",
|
||||
"puppeteer": "^25.16.0",
|
||||
"sharp": "^0.35.4",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^6.4.4",
|
||||
"vite-plugin-cesium": "^1.2.23",
|
||||
"ws": "^8.21.0"
|
||||
@@ -90,6 +89,7 @@
|
||||
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
|
||||
"./application": "./src/app/application.js",
|
||||
"./application/viewer": "./src/app/viewer.js",
|
||||
"./time": "./src/time/worldClock.js",
|
||||
"./build/vite": {
|
||||
"node": "./build/vite.js"
|
||||
},
|
||||
@@ -228,9 +228,6 @@
|
||||
"./standalone/catalog": "./src/standalone/catalog.js",
|
||||
"./application/layers": "./src/app/constructCatalog.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",
|
||||
"./ui/navigation": "./src/ui/navigationController.js",
|
||||
"./ui/share-restoration": "./src/ui/shareRestoration.js",
|
||||
@@ -294,10 +291,6 @@
|
||||
"./tools/panel": "./src/tools/panel.js",
|
||||
"./tools/services": "./src/tools/services.js",
|
||||
"./view": "./src/view/index.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"
|
||||
"./agent/world-packet": "./src/agent/worldPacket.js"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from 'node:assert/strict';
|
||||
import { advanceSpriteFocus } from '../src/data/focusDeemphasis.js';
|
||||
import { aircraftTreatmentAlpha } from '../src/data/aircraftRecession.js';
|
||||
import { createIconSet } from '../src/data/iconSet.js';
|
||||
import {
|
||||
advanceSpriteFocus,
|
||||
} from '../src/data/focusDeemphasis.js';
|
||||
import {
|
||||
applyAircraftBillboardTreatment,
|
||||
} from '../src/data/aircraftRecession.js';
|
||||
|
||||
if (typeof global.gc !== 'function') {
|
||||
throw new Error('focus-allocation-check.mjs requires node --expose-gc');
|
||||
@@ -63,14 +66,17 @@ const focusInput = {
|
||||
target,
|
||||
};
|
||||
|
||||
const baseColor = { withAlpha: (alpha) => ({ alpha }) };
|
||||
const billboard = { scale: 1, color: { alpha: 1 } };
|
||||
const aircraftInput = {
|
||||
billboard,
|
||||
baseScale: 1,
|
||||
baseAlpha: 1,
|
||||
baseColor,
|
||||
focusFactor: 1,
|
||||
cameraDistanceM: 100,
|
||||
cameraHeightM: 1000,
|
||||
};
|
||||
const icons = createIconSet({ capacity: 4 });
|
||||
const row = icons.add('sprite', {});
|
||||
|
||||
const baseline = bytesPerCall(() => focusInput.nowMs);
|
||||
const focus = netOfLoopBaseline(
|
||||
@@ -78,22 +84,30 @@ const focus = netOfLoopBaseline(
|
||||
baseline,
|
||||
);
|
||||
const aircraft = netOfLoopBaseline(
|
||||
bytesPerCall(() => aircraftTreatmentAlpha(aircraftInput)),
|
||||
bytesPerCall(() => applyAircraftBillboardTreatment(aircraftInput).alpha),
|
||||
baseline,
|
||||
);
|
||||
const productionSpriteTick = netOfLoopBaseline(
|
||||
bytesPerCall(() => {
|
||||
// Match the hot icon-set focus pass: the caller constructs a fresh option
|
||||
// literal, consumes the shared focus result immediately and writes it to
|
||||
// the row's focus column (the shader composes it with limb recession).
|
||||
// Match the hot civilian/military sprite path: the caller constructs fresh
|
||||
// option literals, consumes the shared focus result immediately, and then
|
||||
// constructs the composed aircraft-treatment options for the same sprite.
|
||||
const focusResult = advanceSpriteFocus(sprite, {
|
||||
screenPosition: focusInput.screenPosition,
|
||||
cameraDistance: focusInput.cameraDistance,
|
||||
nowMs: focusInput.nowMs,
|
||||
target,
|
||||
});
|
||||
icons.setFocus(row, focusResult.factor);
|
||||
return focusResult.factor;
|
||||
const treatment = applyAircraftBillboardTreatment({
|
||||
billboard,
|
||||
baseScale: aircraftInput.baseScale,
|
||||
baseAlpha: aircraftInput.baseAlpha,
|
||||
baseColor,
|
||||
focusFactor: focusResult.factor,
|
||||
cameraDistanceM: aircraftInput.cameraDistanceM,
|
||||
cameraHeightM: aircraftInput.cameraHeightM,
|
||||
});
|
||||
return focusResult.factor + treatment.alpha;
|
||||
}),
|
||||
baseline,
|
||||
);
|
||||
@@ -104,24 +118,22 @@ assert.ok(
|
||||
);
|
||||
assert.ok(
|
||||
aircraft.roundedMedian <= PURE_CALL_BUDGET,
|
||||
`aircraftTreatmentAlpha allocated ${aircraft.rawMedian.toFixed(3)} B/call median`,
|
||||
`applyAircraftBillboardTreatment allocated ${aircraft.rawMedian.toFixed(3)} B/call median`,
|
||||
);
|
||||
assert.ok(
|
||||
productionSpriteTick.roundedMedian <= PRODUCTION_SPRITE_TICK_BUDGET,
|
||||
`production sprite tick allocated ${productionSpriteTick.rawMedian.toFixed(3)} B/call median`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
callsPerTrial: CALLS,
|
||||
trials: TRIALS,
|
||||
budgets: {
|
||||
pureCall: PURE_CALL_BUDGET,
|
||||
productionSpriteTick: PRODUCTION_SPRITE_TICK_BUDGET,
|
||||
},
|
||||
loopBaseline: baseline,
|
||||
advanceSpriteFocus: focus,
|
||||
aircraftTreatmentAlpha: aircraft,
|
||||
productionSpriteTick,
|
||||
}),
|
||||
);
|
||||
console.log(JSON.stringify({
|
||||
callsPerTrial: CALLS,
|
||||
trials: TRIALS,
|
||||
budgets: {
|
||||
pureCall: PURE_CALL_BUDGET,
|
||||
productionSpriteTick: PRODUCTION_SPRITE_TICK_BUDGET,
|
||||
},
|
||||
loopBaseline: baseline,
|
||||
advanceSpriteFocus: focus,
|
||||
applyAircraftBillboardTreatment: aircraft,
|
||||
productionSpriteTick,
|
||||
}));
|
||||
+94
-48
@@ -43,25 +43,6 @@
|
||||
"scripts/build-admin-packs.mjs",
|
||||
"scripts/qa-admin-outlines.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",
|
||||
"src/main.js",
|
||||
"docs/APPLICATION.md",
|
||||
@@ -680,12 +661,6 @@
|
||||
"src/services/application.js",
|
||||
"src/services/requests.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/data.js",
|
||||
"src/app/tools.js",
|
||||
@@ -1127,7 +1102,6 @@
|
||||
"scripts/qa-anime-sky.mjs",
|
||||
"scripts/qa-sky-model-lighting.mjs",
|
||||
"server/providers/overpass/requestAdmission.test.mjs",
|
||||
"src/tooling/trackArrivalObservation.mjs",
|
||||
"src/tooling/trackArrivalObservation.test.mjs",
|
||||
"src/ui/styles/status-badge.css",
|
||||
"src/ui/cctvBadges.test.mjs",
|
||||
@@ -1136,26 +1110,98 @@
|
||||
"src/cameraGlobeRotation.test.mjs",
|
||||
"src/voice/globeResetConfirmation.test.mjs",
|
||||
"src/hudDms.test.mjs",
|
||||
"server/providers/common/rateLimitKeys.test.mjs",
|
||||
"src/render/contract/contract.test.mjs",
|
||||
"src/render/contract/common.d.ts",
|
||||
"src/render/contract/renderer.d.ts",
|
||||
"src/render/contract/frame.d.ts",
|
||||
"src/render/contract/globe.d.ts",
|
||||
"src/render/contract/draw.d.ts",
|
||||
"src/render/contract/camera.d.ts",
|
||||
"src/render/contract/query.d.ts",
|
||||
"src/render/contract/input.d.ts",
|
||||
"src/render/contract/effects.d.ts",
|
||||
"src/render/contract/credits.d.ts",
|
||||
"src/render/contract/quality.d.ts",
|
||||
"src/render/contract/index.d.ts",
|
||||
"src/render/stub/stub.test.mjs",
|
||||
"src/render/conformance/scenes.test.mjs",
|
||||
"src/render/cesium/cameraGoldens.test.mjs",
|
||||
"scripts/record-camera-goldens.mjs",
|
||||
"server/standalone/conformance-references.js",
|
||||
"dev/conformance/index.html",
|
||||
"dev/conformance/main.js",
|
||||
"docs/RENDERER-CONTRACT.md"
|
||||
"src/hudSummaryGate.test.mjs",
|
||||
"src/voice/hudPlaceLabels.test.mjs",
|
||||
"src/voice/panel/preferences.test.mjs",
|
||||
"src/voice/panel/preferencesView.test.mjs",
|
||||
"src/voice/voiceControlView.test.mjs",
|
||||
"src/voice/panel/host.test.mjs",
|
||||
"src/voice/panel/manualPanel.test.mjs",
|
||||
"src/voice/panel/nativeActivity.test.mjs",
|
||||
"src/voice/panel/inactivity.test.mjs",
|
||||
"src/voice/panel/actionObserver.test.mjs",
|
||||
"src/voice/realtimeCostPanel.test.mjs",
|
||||
"docs/SHARE-LINK-FORMAT.md",
|
||||
"src/share/wire.test.mjs",
|
||||
"src/share/viewCodec.test.mjs",
|
||||
"src/share/legacyShareState.test.mjs",
|
||||
"src/testSupport/shareViewFixtures.mjs",
|
||||
"src/ui/shareLinks.test.mjs",
|
||||
"src/ui/shareButton.test.mjs",
|
||||
"src/share/shareLinkPath.test.mjs",
|
||||
"src/ui/kit/UiRail.test.mjs",
|
||||
"src/ui/kit/UiSegmented.vue",
|
||||
"src/ui/kit/UiSegmented.test.mjs",
|
||||
"src/ui/kit/UiSlider.js",
|
||||
"src/ui/kit/UiSlider.test.mjs",
|
||||
"src/ui/kit/UiButton.vue",
|
||||
"src/ui/kit/UiButton.test.mjs",
|
||||
"src/ui/kit/UiChip.vue",
|
||||
"src/ui/kit/UiChip.test.mjs",
|
||||
"src/ui/globeActions.test.mjs",
|
||||
"src/ui/safeFrameView.test.mjs",
|
||||
"src/ui/kit/UiSelect.js",
|
||||
"src/ui/kit/UiSelect.test.mjs",
|
||||
"src/ui/kit/UiBadge.vue",
|
||||
"src/ui/kit/UiBadge.test.mjs",
|
||||
"src/ui/kit/UiEmpty.vue",
|
||||
"src/ui/rail/RailChips.vue",
|
||||
"src/ui/displayPanelActions.test.mjs",
|
||||
"src/testSupport/vueDom.mjs",
|
||||
"build/vueLoader.js",
|
||||
"build/registerVue.js",
|
||||
"src/ui/panelNavigation.test.mjs",
|
||||
"src/ui/stores/owners.js",
|
||||
"src/ui/stores/owners.test.mjs",
|
||||
"src/ui/stores/panels.test.mjs",
|
||||
"src/ui/stores/status.js",
|
||||
"src/ui/statusChipsView.test.mjs",
|
||||
"src/ui/statusChipOwners.test.mjs",
|
||||
"src/ui/toast.js",
|
||||
"src/ui/toastView.test.mjs",
|
||||
"src/ui/stores/sceneInteractions.js",
|
||||
"src/scenes/dataPacks/presentationView.test.mjs",
|
||||
"src/ui/stores/dataPacks.js",
|
||||
"src/scenes/interactionsView.test.mjs",
|
||||
"src/layers/wind/inspectionMarker.js",
|
||||
"src/layers/wind/inspectionMarker.test.mjs",
|
||||
"src/ui/stores/windInspection.js",
|
||||
"src/ui/stores/missionReplay.js",
|
||||
"src/layers/launches/replayOverlay.test.mjs",
|
||||
"src/ui/stores/bhoteKoshiPanel.js",
|
||||
"src/data/bhoteKoshiPanelView.test.mjs",
|
||||
"src/ui/stores/celestialRing.js",
|
||||
"src/celestialRingView.test.mjs",
|
||||
"src/ui/stores/screenAnnotations.js",
|
||||
"src/ui/stores/worldOverlayActions.js",
|
||||
"src/overlays/worldOverlayActionsView.test.mjs",
|
||||
"src/ui/stores/awarenessDirection.js",
|
||||
"src/layers/awareness/directionOverlay.test.mjs",
|
||||
"src/hudReadouts.js",
|
||||
"src/ui/stores/hud.js",
|
||||
"src/ui/views/intelHudView.test.mjs",
|
||||
"src/testSupport/hudReadouts.mjs",
|
||||
"src/ui/stores/firstRun.js",
|
||||
"src/firstRunLauncherView.test.mjs",
|
||||
"src/firstRunCopy.js",
|
||||
"src/data/headingDegrees.test.mjs",
|
||||
"src/data/distanceFormat.test.mjs",
|
||||
"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"
|
||||
]
|
||||
+1
-2
@@ -479,8 +479,7 @@ function readMarkup(file) {
|
||||
|
||||
// Text that never reaches the page as UI: tool results and descriptions for
|
||||
// AI clients (MCP and the voice model).
|
||||
// The conformance suite's check titles are developer output, not UI.
|
||||
const NOT_UI = [/^src\/tools\//, /^src\/render\/conformance\//];
|
||||
const NOT_UI = [/^src\/tools\//];
|
||||
|
||||
function sourceFiles() {
|
||||
return trackedFiles(
|
||||
|
||||
+48
-808
File diff suppressed because it is too large.
Load diff
@@ -127,7 +127,7 @@ try {
|
||||
let animeOn = false;
|
||||
for (let i = 0; i < s.postProcessStages.length; i++) {
|
||||
const stage = s.postProcessStages.get(i);
|
||||
if (stage.name === 'effects_style_anime')
|
||||
if (stage.name === 'godsEyeView_anime')
|
||||
animeOn = stage.enabled && stage.uniforms.intensity > 0.99;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -1669,7 +1669,7 @@ async function main() {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
let n = 0;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
if (window.__godsEyeView.entityRecords.read(prims.get(i))?.viewshedCameraId) n += 1;
|
||||
if (prims.get(i) && prims.get(i)._gevViewshed) n += 1;
|
||||
}
|
||||
return n;
|
||||
});
|
||||
@@ -1729,7 +1729,7 @@ async function main() {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
window.__qaViewshedRefs = [];
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
if (window.__godsEyeView.entityRecords.read(prims.get(i))?.viewshedCameraId)
|
||||
if (prims.get(i) && prims.get(i)._gevViewshed)
|
||||
window.__qaViewshedRefs.push(prims.get(i));
|
||||
}
|
||||
});
|
||||
@@ -1738,7 +1738,7 @@ async function main() {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
const now = [];
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
if (window.__godsEyeView.entityRecords.read(prims.get(i))?.viewshedCameraId) now.push(prims.get(i));
|
||||
if (prims.get(i) && prims.get(i)._gevViewshed) now.push(prims.get(i));
|
||||
}
|
||||
const before = window.__qaViewshedRefs || [];
|
||||
const sameCount = now.length === before.length;
|
||||
|
||||
@@ -763,7 +763,7 @@ try {
|
||||
} : null,
|
||||
navigation: snapshot?.navigation || null,
|
||||
afterId: trackedInfo?.icao24 || null,
|
||||
viewerTrackedId: window.__godsEyeView.entityRecords.read(viewer.trackedEntity)?.trackedId || null,
|
||||
viewerTrackedId: viewer.trackedEntity?.gevTrackedId || null,
|
||||
contextVisible: !document.getElementById('military-awareness-panel')?.hidden,
|
||||
entryAvailableAfterSettlement: Boolean(entry && !entry.hidden),
|
||||
blockerStillRegistered: dataManager.layers.has(blockerId),
|
||||
@@ -1362,7 +1362,7 @@ try {
|
||||
contextMode: styleManager._contextControls._contextMode,
|
||||
contextChanging: styleManager._contextControls._contextModeChanging,
|
||||
subject: context?.subject ? `${context.subject.layerId}:${context.subject.id}` : null,
|
||||
tracked: window.__godsEyeView.entityRecords.read(tracker)?.trackedId || null,
|
||||
tracked: tracker?.gevTrackedId || null,
|
||||
density: styleManager.getDetectionState().densityPct,
|
||||
detectionMode: styleManager.getDetectionState().detectionMode,
|
||||
installations: dataManager.getLayerLifecycleState('military-installations'),
|
||||
|
||||
@@ -360,9 +360,7 @@ async function contacts() {
|
||||
entities: ds?.entities.values.length ?? 0,
|
||||
created,
|
||||
tracked:
|
||||
window.__godsEyeView.entityRecords.read(
|
||||
window.__godsEyeView.viewer.trackedEntity,
|
||||
)?.trackedId ?? null,
|
||||
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ?? null,
|
||||
cockpit: document.body.classList.contains('cockpit-mode'),
|
||||
});
|
||||
if (samples.length > 5000) samples.splice(0, 1000);
|
||||
@@ -377,9 +375,8 @@ async function contacts() {
|
||||
await page
|
||||
.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.entityRecords.read(
|
||||
window.__godsEyeView.viewer.trackedEntity,
|
||||
)?.trackedId === 'flights:aaa051',
|
||||
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ===
|
||||
'flights:aaa051',
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
|
||||
+68
-32
@@ -263,16 +263,39 @@ try {
|
||||
}),
|
||||
);
|
||||
|
||||
// The viewer's own select and follow, which the input port runs: on before
|
||||
// the tool runs, off while drawing, and on again after.
|
||||
const stockActions = async () => {
|
||||
const state = await drawState();
|
||||
return {
|
||||
clickOn: state.stockSingleClick,
|
||||
doubleOn: state.stockDoubleClick,
|
||||
// The viewer's OWN click actions, captured before the tool ever runs. Both
|
||||
// must be absent while drawing and back — as the SAME functions — after.
|
||||
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,
|
||||
};
|
||||
};
|
||||
const beforeDraw = await stockActions();
|
||||
});
|
||||
const 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 ────────────────────────
|
||||
const before = await drawState();
|
||||
@@ -295,21 +318,24 @@ try {
|
||||
`handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`,
|
||||
);
|
||||
|
||||
// ── the viewer's own click actions are held off, then given back ───────
|
||||
// ── the viewer's own click actions are borrowed, then given back ────────
|
||||
await page.click('#draw-toggle');
|
||||
const duringDraw = await stockActions();
|
||||
check(
|
||||
"Cesium's own selecting click and tracking double-click are both off while drawing",
|
||||
duringDraw.clickOn === false && duringDraw.doubleOn === false,
|
||||
JSON.stringify({ beforeDraw, duringDraw }),
|
||||
"Cesium's own selecting click and tracking double-click are both borrowed while drawing",
|
||||
duringDraw.clickPresent === false && duringDraw.doublePresent === false,
|
||||
`click present ${duringDraw.clickPresent}, double present ${duringDraw.doublePresent} ` +
|
||||
`(viewer had click ${duringDraw.hadClick}, double ${duringDraw.hadDouble})`,
|
||||
);
|
||||
await page.click('#draw-toggle');
|
||||
const afterDraw = await stockActions();
|
||||
check(
|
||||
'and both are back after',
|
||||
afterDraw.clickOn === beforeDraw.clickOn &&
|
||||
afterDraw.doubleOn === beforeDraw.doubleOn,
|
||||
JSON.stringify({ beforeDraw, afterDraw }),
|
||||
'and both are restored as the SAME functions, not replacements',
|
||||
afterDraw.clickPresent === duringDraw.hadClick &&
|
||||
afterDraw.doublePresent === duringDraw.hadDouble &&
|
||||
afterDraw.clickIsOriginal &&
|
||||
afterDraw.doubleIsOriginal,
|
||||
JSON.stringify(afterDraw),
|
||||
);
|
||||
|
||||
// ── a pin, finished with Enter ──────────────────────────────────────────
|
||||
@@ -638,6 +664,19 @@ try {
|
||||
document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0,
|
||||
{ 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) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
@@ -788,20 +827,24 @@ try {
|
||||
const wasActive = tool.active;
|
||||
await tool.destroy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const Cesium = window.__CESIUM__;
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const stock = viewer.screenSpaceEventHandler;
|
||||
let previews = 0;
|
||||
for (let i = 0; i < viewer.dataSources.length; i += 1)
|
||||
if (viewer.dataSources.get(i)?.name === 'gev-draw-preview') previews += 1;
|
||||
const diagnostics = tool.diagnostics();
|
||||
return {
|
||||
wasActive,
|
||||
diagnostics,
|
||||
diagnostics: tool.diagnostics(),
|
||||
previews,
|
||||
dataSources: viewer.dataSources.length,
|
||||
handleGone: window.__gevDrawTool === undefined,
|
||||
// The viewer's own select and follow run again.
|
||||
clickRestored: diagnostics.stockSingleClick,
|
||||
doubleRestored: diagnostics.stockDoubleClick,
|
||||
clickRestored:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) ===
|
||||
window.__qaStock.click,
|
||||
doubleRestored:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ===
|
||||
window.__qaStock.double,
|
||||
drawingClass: document.body.classList.contains('gev-drawing'),
|
||||
};
|
||||
});
|
||||
@@ -898,11 +941,6 @@ async function findClickableTarget() {
|
||||
const site = await page
|
||||
.evaluate(async () => {
|
||||
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(
|
||||
'local-datacenters',
|
||||
true,
|
||||
@@ -922,7 +960,7 @@ async function findClickableTarget() {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
for (let i = 0; i < viewer.dataSources.length; i += 1) {
|
||||
for (const entity of viewer.dataSources.get(i).entities.values) {
|
||||
if (!isDataCentre(entity)) continue;
|
||||
if (entity.__localLayerId !== 'local-datacenters') continue;
|
||||
const point = centreOf(entity);
|
||||
if (!point) continue;
|
||||
const carto = window.Cesium
|
||||
@@ -955,10 +993,8 @@ async function findClickableTarget() {
|
||||
viewer.scene.requestRender();
|
||||
}, site);
|
||||
await wait(4000);
|
||||
const spot = await onScreen((entity) =>
|
||||
window.__godsEyeView.entityRecords
|
||||
.read(entity)
|
||||
?.contextId?.startsWith('local-datacenters:'),
|
||||
const spot = await onScreen(
|
||||
(entity) => entity.__localLayerId === 'local-datacenters',
|
||||
);
|
||||
return spot ? { ...spot, layer: 'data centre' } : null;
|
||||
}
|
||||
|
||||
@@ -169,8 +169,8 @@ async function observeTrackedHostPaint(page, timeoutMs = 5000) {
|
||||
const tracked = viewer?.trackedEntity;
|
||||
const current = {
|
||||
elapsedMs: Math.round(performance.now() - startedAt),
|
||||
entryId: window.__godsEyeView?.entityRecords?.read(tracked)?.trackedId || null,
|
||||
hasPresentationModel: Boolean(window.__godsEyeView?.entityRecords?.read(tracked)?.labelModel?.title),
|
||||
entryId: tracked?.gevTrackedId || null,
|
||||
hasPresentationModel: Boolean(tracked?.gevLabelModel?.title),
|
||||
candidateCount: diagnostics.candidateCount || 0,
|
||||
projectedCount: diagnostics.projectedCount || 0,
|
||||
selectedCount: diagnostics.selectedCount || 0,
|
||||
@@ -610,7 +610,7 @@ async function main() {
|
||||
if (!pool.length) return null;
|
||||
const ent = v.trackedEntity;
|
||||
let tracked = null;
|
||||
if (ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function') tracked = window.__godsEyeView.entityRecords.read(ent)?.displayPosition();
|
||||
if (ent && typeof ent.gevDisplayPosition === 'function') tracked = ent.gevDisplayPosition();
|
||||
if (!tracked) return pool[0];
|
||||
let best = null, bestD = Infinity;
|
||||
for (const m of pool) {
|
||||
|
||||
@@ -218,11 +218,9 @@ try {
|
||||
const gev = window.__godsEyeView;
|
||||
const snap = () => infraIds.map((id) => {
|
||||
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 (const entity of gev.viewer.dataSources.get(i).entities.values) {
|
||||
const contextId = gev.entityRecords.read(entity)?.contextId;
|
||||
if (contextId?.startsWith(`${id}:`) && entity.show) visibleIds.push(String(entity.id));
|
||||
if (entity.__localLayerId === id && entity.show) visibleIds.push(String(entity.id));
|
||||
}
|
||||
}
|
||||
return visibleIds.sort();
|
||||
|
||||
@@ -17,11 +17,9 @@ export async function checkInstallationPan(page, dir) {
|
||||
'military-installations',
|
||||
).module;
|
||||
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 (const e of g.viewer.dataSources.get(i).entities.values)
|
||||
if (g.entityRecords.read(e)?.contextId?.startsWith('local-datacenters:'))
|
||||
centerIds.push(e.id);
|
||||
if (e.__localLayerId === 'local-datacenters') centerIds.push(e.id);
|
||||
window.__polishPanFrames = [];
|
||||
window.__stopPolishPan = g.viewer.scene.postRender.addEventListener(() => {
|
||||
const sites = [],
|
||||
@@ -335,8 +333,7 @@ export async function checkInstallationSelection(
|
||||
for (let y = 250; y < 650; y += 75)
|
||||
for (let x = 400; x < 850; x += 75) {
|
||||
const pick = g.viewer.scene.pick({ x, y });
|
||||
// Nothing picked: no installation marker, outline or tracked contact.
|
||||
if (!pick?.id)
|
||||
if (!pick?.id?.installationId && !pick?.id?.gevTrackedId)
|
||||
return { x, y };
|
||||
}
|
||||
throw new Error('No empty map point');
|
||||
|
||||
@@ -1277,9 +1277,8 @@ try {
|
||||
outlines = 0;
|
||||
for (let i = 0; i < viewer.dataSources.length; i++)
|
||||
for (const entity of viewer.dataSources.get(i).entities.values) {
|
||||
// Outline entities are `<record id>:outline:<ring>`.
|
||||
const recordId = String(entity.id).replace(/:outline:\d+$/, '');
|
||||
if (!ids.has(recordId) || !entity.show) continue;
|
||||
if (!ids.has(entity.installationId || entity.id) || !entity.show)
|
||||
continue;
|
||||
if (entity.billboard || entity.point) markers++;
|
||||
if (entity.polyline) outlines++;
|
||||
}
|
||||
|
||||
+9
-26
@@ -3073,27 +3073,20 @@ async function main() {
|
||||
expectedSlot: targetIndex,
|
||||
previewPlayDelta: previewPlays - playsBefore,
|
||||
commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore,
|
||||
audioState: state.audioState,
|
||||
tuningStatic: state.tuningStatic,
|
||||
samples,
|
||||
spread: Math.max(...samples) - Math.min(...samples),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'from a stopped Radio, pointer release only selects the nearest station, silently and without a needle sweep',
|
||||
'pointer preview never plays and release commits the nearest station once without a needle sweep',
|
||||
tunerDirectRelease.targetId
|
||||
&& tunerDirectRelease.selectedId === tunerDirectRelease.targetId
|
||||
&& tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot
|
||||
&& tunerDirectRelease.previewPlayDelta === 0
|
||||
&& tunerDirectRelease.commitPlayDelta === 0
|
||||
&& tunerDirectRelease.audioState === 'stopped'
|
||||
&& !tunerDirectRelease.tuningStatic
|
||||
&& tunerDirectRelease.commitPlayDelta === 1
|
||||
&& tunerDirectRelease.spread < 0.5,
|
||||
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
|
||||
// target back into its scroller before deriving viewport mouse coordinates.
|
||||
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
|
||||
@@ -3501,9 +3494,6 @@ async function main() {
|
||||
});
|
||||
});
|
||||
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 gev = window.__godsEyeView;
|
||||
const slider = document.getElementById('radio-tuner-slider');
|
||||
@@ -3702,14 +3692,7 @@ async function main() {
|
||||
radio.stopPlayback();
|
||||
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
|
||||
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
|
||||
// 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;
|
||||
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
|
||||
const pool = gev.styleManager._radioControls._radioTunerPool;
|
||||
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
|
||||
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
|
||||
@@ -3817,7 +3800,7 @@ async function main() {
|
||||
id: `qa:${ownerPrefix}:radio-camera-owner`,
|
||||
position: viewer.camera.positionWC.clone(),
|
||||
});
|
||||
window.__godsEyeView.entityRecords.write(owner).trackedId = `${ownerPrefix}:qa-radio-camera-owner`;
|
||||
owner.gevTrackedId = `${ownerPrefix}:qa-radio-camera-owner`;
|
||||
viewer.trackedEntity = owner;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const beforeStationId = radio.getUIState().selected?.id || null;
|
||||
@@ -3831,7 +3814,7 @@ async function main() {
|
||||
afterStationId: radio.getUIState().selected?.id || null,
|
||||
flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls,
|
||||
trackedIdentityPreserved: viewer.trackedEntity === owner,
|
||||
trackedId: window.__godsEyeView.entityRecords.read(viewer.trackedEntity)?.trackedId || null,
|
||||
trackedId: viewer.trackedEntity?.gevTrackedId || null,
|
||||
};
|
||||
viewer.trackedEntity = undefined;
|
||||
viewer.entities.remove(owner);
|
||||
@@ -3867,11 +3850,11 @@ async function main() {
|
||||
id: 'qa:flights:delayed-radio-fallback-owner',
|
||||
position: viewer.camera.positionWC.clone(),
|
||||
});
|
||||
window.__godsEyeView.entityRecords.write(owner).trackedId = 'flights:qa-delayed-radio-fallback-owner';
|
||||
owner.gevTrackedId = 'flights:qa-delayed-radio-fallback-owner';
|
||||
viewer.trackedEntity = owner;
|
||||
window.__qaDelayedRadioTrackingOwner = owner;
|
||||
window.__qaRejectRadioPlay();
|
||||
return window.__godsEyeView.entityRecords.read(owner)?.trackedId;
|
||||
return owner.gevTrackedId;
|
||||
});
|
||||
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||
const delayedFallbackOwnership = await page.evaluate((beforeFlyToCalls) => {
|
||||
@@ -3880,7 +3863,7 @@ async function main() {
|
||||
const result = {
|
||||
flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls,
|
||||
trackedIdentityPreserved: viewer.trackedEntity === owner,
|
||||
trackedId: window.__godsEyeView.entityRecords.read(viewer.trackedEntity)?.trackedId || null,
|
||||
trackedId: viewer.trackedEntity?.gevTrackedId || null,
|
||||
selectedId: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id || null,
|
||||
};
|
||||
viewer.trackedEntity = undefined;
|
||||
@@ -4222,7 +4205,7 @@ async function main() {
|
||||
id: 'qa:flights:radio-recenter-stage-owner',
|
||||
position: viewer.camera.positionWC.clone(),
|
||||
});
|
||||
window.__godsEyeView.entityRecords.write(owner).trackedId = 'flights:qa-radio-recenter-stage-owner';
|
||||
owner.gevTrackedId = 'flights:qa-radio-recenter-stage-owner';
|
||||
viewer.trackedEntity = owner;
|
||||
completeFlight(0);
|
||||
const trackingBetweenStagesCalls = calls.length;
|
||||
|
||||
@@ -169,7 +169,7 @@ try {
|
||||
result.bloom.intensityPct === 98 &&
|
||||
snapshot.bloom.intensity === 98 &&
|
||||
manager._visualEffects.bloomIntensity === 98 &&
|
||||
manager.viewer.scene.postProcessStages.bloom.enabled
|
||||
manager._bloomStage.enabled
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -183,11 +183,7 @@ try {
|
||||
result.sharpen.intensityPct === 61 &&
|
||||
manager.getVisualState().sharpen.intensity === 61 &&
|
||||
manager._visualEffects.sharpenIntensity === 0.61 &&
|
||||
Math.abs(
|
||||
manager.viewer.scene.postProcessStages.getStageByName(
|
||||
'effects_sharpen',
|
||||
).uniforms.amount - 1.32,
|
||||
) < 1e-9
|
||||
Math.abs(manager._sharpenStage.uniforms.amount - 1.32) < 1e-9
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -203,10 +199,9 @@ try {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const effects = manager._visualEffects;
|
||||
manager.setStyle('retro');
|
||||
const owned = [...Object.values(effects.stages), effects.sharpenStage];
|
||||
const collection = manager.viewer.scene.postProcessStages;
|
||||
const owned = Object.keys(effects.stages).map((name) =>
|
||||
collection.getStageByName(`effects_style_${name}`),
|
||||
);
|
||||
const previousBloom = effects.previousBloom;
|
||||
const pending = manager.dispose();
|
||||
const stoppedBeforeAwait =
|
||||
effects.stopped &&
|
||||
@@ -215,15 +210,12 @@ try {
|
||||
await pending;
|
||||
return {
|
||||
stoppedBeforeAwait,
|
||||
removed:
|
||||
owned.every(Boolean) &&
|
||||
Object.keys(effects.stages).every(
|
||||
(name) => !collection.getStageByName(`effects_style_${name}`),
|
||||
),
|
||||
// The renderer gives the scene's bloom back when it goes.
|
||||
removed: owned.every((stage) => !collection.contains(stage)),
|
||||
restored:
|
||||
!collection.bloom.enabled &&
|
||||
!collection.getStageByName('effects_sharpen')?.enabled,
|
||||
effects.bloomStage.enabled === previousBloom.enabled &&
|
||||
Object.entries(previousBloom.uniforms).every(
|
||||
([name, value]) => effects.bloomStage.uniforms[name] === value,
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
@@ -231,7 +223,7 @@ try {
|
||||
teardown.stoppedBeforeAwait,
|
||||
);
|
||||
check(
|
||||
'UI teardown removes its styles and turns bloom and sharpen off',
|
||||
'UI teardown removes owned stages and restores borrowed bloom',
|
||||
teardown.removed && teardown.restored,
|
||||
);
|
||||
check(
|
||||
|
||||
@@ -150,7 +150,7 @@ try {
|
||||
manager._bloomSlider.focus();
|
||||
return {
|
||||
value: Number(manager._bloomSlider.value),
|
||||
contrast: manager.viewer.scene.postProcessStages.bloom.uniforms.contrast,
|
||||
contrast: manager._bloomStage.uniforms.contrast,
|
||||
};
|
||||
});
|
||||
await page.keyboard.press('ArrowRight');
|
||||
@@ -160,8 +160,7 @@ try {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
return (
|
||||
Number(manager._bloomSlider.value) > before.value &&
|
||||
manager.viewer.scene.postProcessStages.bloom.uniforms.contrast <
|
||||
before.contrast
|
||||
manager._bloomStage.uniforms.contrast < before.contrast
|
||||
);
|
||||
}, displayBefore),
|
||||
);
|
||||
@@ -268,15 +267,13 @@ try {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager._displayBindings._detachDisplayControls();
|
||||
const before = manager.bloomEnabled;
|
||||
const contrast =
|
||||
manager.viewer.scene.postProcessStages.bloom.uniforms.contrast;
|
||||
const contrast = manager._bloomStage.uniforms.contrast;
|
||||
manager._bloomBtn.click();
|
||||
manager._bloomSlider.value = '15';
|
||||
manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return (
|
||||
manager.bloomEnabled === before &&
|
||||
manager.viewer.scene.postProcessStages.bloom.uniforms.contrast ===
|
||||
contrast
|
||||
manager._bloomStage.uniforms.contrast === contrast
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/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 puppeteer from 'puppeteer';
|
||||
import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
||||
import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
||||
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
||||
|
||||
@@ -701,7 +701,7 @@ async function main() {
|
||||
const tracked = manager?.layers?.get('flights')?.module?.getTrackedInfo?.();
|
||||
return manager?.isEnabled?.('flights')
|
||||
&& tracked?.icao24 === 'aaa001'
|
||||
&& window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId === 'flights:aaa001';
|
||||
&& window.__godsEyeView.viewer.trackedEntity?.gevTrackedId === 'flights:aaa001';
|
||||
},
|
||||
{ timeout: 30000, polling: 100 },
|
||||
);
|
||||
@@ -712,7 +712,7 @@ async function main() {
|
||||
flightsEnabled: manager.isEnabled('flights'),
|
||||
trackedId: flights.getTrackedInfo()?.icao24 || null,
|
||||
durableId: flights.getParams()?.selectedFlightsTrackingId || null,
|
||||
viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null,
|
||||
viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null,
|
||||
};
|
||||
});
|
||||
record(
|
||||
@@ -742,7 +742,7 @@ async function main() {
|
||||
const tracked = manager?.layers?.get('flights')?.module?.getTrackedInfo?.();
|
||||
return manager?.isEnabled?.('flights')
|
||||
&& tracked?.icao24 === 'aaa001'
|
||||
&& window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId === 'flights:aaa001';
|
||||
&& window.__godsEyeView.viewer.trackedEntity?.gevTrackedId === 'flights:aaa001';
|
||||
},
|
||||
{ timeout: 30000, polling: 100 },
|
||||
);
|
||||
@@ -753,7 +753,7 @@ async function main() {
|
||||
flightsEnabled: manager.isEnabled('flights'),
|
||||
trackedId: flights.getTrackedInfo()?.icao24 || null,
|
||||
durableId: flights.getParams()?.selectedFlightsTrackingId || null,
|
||||
viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null,
|
||||
viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null,
|
||||
};
|
||||
});
|
||||
record(
|
||||
@@ -815,7 +815,7 @@ async function main() {
|
||||
const latchedShare = await evalPage(() => ({
|
||||
tracked: window.__godsEyeView.dataManager.layers
|
||||
.get('flights').module.getTrackedInfo()?.icao24 || null,
|
||||
viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null,
|
||||
viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null,
|
||||
}));
|
||||
record(
|
||||
'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;
|
||||
const ent = window.__godsEyeView.viewer.trackedEntity;
|
||||
let tracked = null;
|
||||
if (ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function') tracked = window.__godsEyeView.entityRecords.read(ent)?.displayPosition();
|
||||
if (ent && typeof ent.gevDisplayPosition === 'function') tracked = ent.gevDisplayPosition();
|
||||
if (!tracked) return pool[0];
|
||||
let best = null, bestD = Infinity;
|
||||
for (const m of pool) {
|
||||
@@ -1427,7 +1427,7 @@ async function main() {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const fl = dm.layers.get('flights').module;
|
||||
const ent = window.__godsEyeView.viewer.trackedEntity;
|
||||
const disp = ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null;
|
||||
const disp = ent && typeof ent.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null;
|
||||
const dets = fl.getDetectableObjects({ maxCount: 1000 });
|
||||
const me = dets.find((d) => d.id === icao || d.id === (fl.getTrackedInfo() && fl.getTrackedInfo().callsign));
|
||||
if (!disp || !me || !me.position) return null;
|
||||
@@ -1656,7 +1656,7 @@ async function main() {
|
||||
const ti = fl.getTrackedInfo();
|
||||
if (!ti) return null;
|
||||
const ent = v.trackedEntity;
|
||||
const disp = ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null;
|
||||
const disp = ent && typeof ent.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null;
|
||||
const pos = disp || ti.position;
|
||||
if (!pos) return null;
|
||||
const center = v.scene.cartesianToCanvasCoordinates(pos);
|
||||
@@ -1908,7 +1908,7 @@ async function main() {
|
||||
// presentation model (entities are asserted native-label-free);
|
||||
// the native read stays as a fallback so this probe still fails
|
||||
// loudly if BOTH surfaces are ever absent.
|
||||
const model = window.__godsEyeView.entityRecords.read(ent)?.labelModel;
|
||||
const model = ent.gevLabelModel;
|
||||
if (model) return [model.title, ...(model.details || [])].filter(Boolean).join('\n');
|
||||
if (!ent.label || !ent.label.text) return null;
|
||||
const t = ent.label.text;
|
||||
@@ -2553,7 +2553,7 @@ async function main() {
|
||||
}
|
||||
|
||||
// WELD (tracked): the tracked CARD anchors to the model you can see.
|
||||
// the record's `visualPosition` is a SEPARATE accessor from its `displayPosition` on
|
||||
// `gevVisualPosition` is a SEPARATE accessor from `gevDisplayPosition` on
|
||||
// purpose — the latter carries the follow-camera anti-jitter contract and
|
||||
// must keep returning the cached dead-reckoned value the camera settled
|
||||
// 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(
|
||||
p.x - anchor.x, p.y - anchor.y, p.z - anchor.z,
|
||||
) : null);
|
||||
const visual = typeof window.__godsEyeView.entityRecords.read(ent)?.visualPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.visualPosition() : null;
|
||||
const display = typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null;
|
||||
const visual = typeof ent?.gevVisualPosition === 'function' ? ent.gevVisualPosition() : null;
|
||||
const display = typeof ent?.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null;
|
||||
return {
|
||||
hasVisualAccessor: typeof window.__godsEyeView.entityRecords.read(ent)?.visualPosition === 'function',
|
||||
hasVisualAccessor: typeof ent?.gevVisualPosition === 'function',
|
||||
visualDelta: at(visual),
|
||||
displayDelta: at(display),
|
||||
};
|
||||
@@ -2599,7 +2599,7 @@ async function main() {
|
||||
// anti-jitter accessor has been repurposed and the follow camera is at
|
||||
// risk. Skipped (not failed) when the DR cache is momentarily invalid.
|
||||
const displayDelta = trackedWeld.displayDelta;
|
||||
record('weld: the record displayPosition still reports the dead-reckoned position (anti-jitter intact)',
|
||||
record('weld: gevDisplayPosition still reports the dead-reckoned position (anti-jitter intact)',
|
||||
!Number.isFinite(displayDelta) || displayDelta > ACCESSOR_SEPARATION_EPSILON_M,
|
||||
Number.isFinite(displayDelta)
|
||||
? `displayΔ=${displayDelta.toFixed(3)} m (must stay > ${ACCESSOR_SEPARATION_EPSILON_M} m — the ground-snap split)`
|
||||
@@ -2698,18 +2698,16 @@ async function main() {
|
||||
// Staged deterministically: park the camera nadir over the synthetic
|
||||
// cluster, let rotations settle, TAMPER a billboard's rotation, then
|
||||
// trigger each path with the camera idle. The fleet runs every 80 ms,
|
||||
// independently of rendered FPS. Observe the first completed settle frame
|
||||
// within 500 ms of forcing moveEnd and the first reveal frame within
|
||||
// 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.
|
||||
// independently of rendered FPS. Observe the first completed settle and
|
||||
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
|
||||
// rotation refresh can repair an incorrect first reveal.
|
||||
// ============================================================
|
||||
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
|
||||
const arrival = await evalPage(async () => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
|
||||
const { observeArrivalRotationWithRetry } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
|
||||
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
|
||||
// 3D models OFF for this phase: a model-handed-off billboard is hidden
|
||||
// and skips rotation updates entirely — the probes need live billboards
|
||||
// (this is also the app's default state the field report came from).
|
||||
@@ -2771,23 +2769,7 @@ async function main() {
|
||||
const bb = findBB(id);
|
||||
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
|
||||
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
|
||||
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);
|
||||
},
|
||||
const observed = await observeArrivalRotation({
|
||||
scene: v.scene,
|
||||
startMoveEnd() {
|
||||
// Only moveEnd can repair the tampered angle at the idle pose.
|
||||
@@ -2808,6 +2790,7 @@ async function main() {
|
||||
};
|
||||
},
|
||||
});
|
||||
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
||||
return { r0, ...observed, hasModel };
|
||||
};
|
||||
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
|
||||
@@ -2817,8 +2800,8 @@ async function main() {
|
||||
military: await probe('bbb101', militaryCourse),
|
||||
};
|
||||
});
|
||||
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 attempts=${a.attempts} hasModel=${a.hasModel}`);
|
||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
|
||||
: `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}`);
|
||||
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)}°)`,
|
||||
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isIPv6 } from 'node:net';
|
||||
import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
||||
export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
||||
|
||||
@@ -61,46 +60,3 @@ export function makeCostRateLimiter(envValue, defaultPerMin) {
|
||||
export function clientKey(req) {
|
||||
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`;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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
|
||||
* (`/api/terrain/heights`, `/api/overpass`). It refuses the same cross-site browser shapes as
|
||||
* (`/api/terrain/heights`). It refuses the same cross-site browser shapes as
|
||||
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
|
||||
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
|
||||
* those protect credentials and spend, which a keyless route has neither of,
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { SNAPSHOT_FEED_STATES } from '../../../src/data/layerSnapshot.js';
|
||||
import {
|
||||
HUD_SUMMARY_MAX_CHARS,
|
||||
HUD_SUMMARY_MAX_UNSPACED_CHARS,
|
||||
HUD_SUMMARY_RESPONSE_FORMAT,
|
||||
hudSummaryInput,
|
||||
hudSummaryInstructions,
|
||||
hudSummaryIsUnspaced,
|
||||
hudSummaryLocale,
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
keylessHudSummaryResponse,
|
||||
} from '../../../src/hudSummaryResponse.js';
|
||||
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
@@ -25,58 +18,14 @@ function extractOpenAiResponseText(data) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Parse the summary and normalize the canonical feed state. */
|
||||
function parseHudSummaryReply(text, locale) {
|
||||
let reply = null;
|
||||
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, ' ')
|
||||
function toFiveWordHudSummary(value) {
|
||||
return String(value || '')
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((word) => word.replace(/^['’·・-]+|['’·・-]+$/gu, ''))
|
||||
.filter(Boolean);
|
||||
// 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);
|
||||
.filter(Boolean)
|
||||
.slice(0, 5)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
async function handleHudSummary(req, res) {
|
||||
@@ -128,18 +77,14 @@ async function handleHudSummary(req, res) {
|
||||
model:
|
||||
process.env.OPENAI_HUD_SUMMARY_MODEL ||
|
||||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
||||
instructions: hudSummaryInstructions(context?.locale),
|
||||
input: hudSummaryInput(context),
|
||||
text: { format: HUD_SUMMARY_RESPONSE_FORMAT },
|
||||
instructions: HUD_SUMMARY_INSTRUCTIONS,
|
||||
input: JSON.stringify(context),
|
||||
reasoning: { effort: 'minimal' },
|
||||
max_output_tokens: 200,
|
||||
max_output_tokens: 100,
|
||||
}),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const { summary, feedState } = parseHudSummaryReply(
|
||||
extractOpenAiResponseText(data),
|
||||
context?.locale,
|
||||
);
|
||||
const summary = toFiveWordHudSummary(extractOpenAiResponseText(data));
|
||||
res.statusCode = response.ok && summary ? 200 : response.status || 502;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
@@ -148,7 +93,6 @@ async function handleHudSummary(req, res) {
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
summary: summary || null,
|
||||
feedState,
|
||||
// Never relay `data.error.message`: that is OpenAI's own wording, and
|
||||
// it carries request ids, organization hints and quota phrasing.
|
||||
error: response.ok ? null : 'OpenAI HUD summary request failed',
|
||||
@@ -166,4 +110,4 @@ async function handleHudSummary(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
export { handleHudSummary, parseHudSummaryReply };
|
||||
export { handleHudSummary };
|
||||
@@ -1,12 +1,6 @@
|
||||
import {
|
||||
makeRateLimiter,
|
||||
clientKey,
|
||||
clientNetworkKey,
|
||||
isLoopbackPeer,
|
||||
} from './common/rate-limit.js';
|
||||
import { hasProxySignals } from '../../src/localRequestGate.mjs';
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import { readRequestBodyCapped } from './common/request.js';
|
||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||
import { admitSameSite } from './common/same-site.js';
|
||||
import {
|
||||
OVERPASS_MAX_BODY_BYTES,
|
||||
OVERPASS_MAX_CONCURRENT,
|
||||
@@ -35,21 +29,10 @@ const _overpassInFlight = new Map();
|
||||
let _overpassConcurrent = 0;
|
||||
|
||||
// Bound body-reading and validation work, including malformed requests and
|
||||
// cache hits, before a byte is read. Remote peers share one global window so
|
||||
// address rotation cannot mint admission without bound (IPv6 peers are keyed
|
||||
// by /64 for the same reason). A direct loopback request — the owner's own
|
||||
// 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,
|
||||
// cache hits. Keep the tighter upstream budget below independent of this gate.
|
||||
const _overpassRequestLimiter = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: 300,
|
||||
globalMax: 900,
|
||||
});
|
||||
|
||||
@@ -59,33 +42,6 @@ const _overpassRateLimiter = makeRateLimiter({
|
||||
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.
|
||||
*
|
||||
@@ -147,13 +103,8 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keyless gate: refuses cross-site browser requests but, unlike the
|
||||
// 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)) {
|
||||
if (admitSameSite(req, res)) return;
|
||||
if (!_overpassRequestLimiter(clientKey(req))) {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
@@ -212,7 +163,7 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
// requests; boundary-class queries keep their month-long TTL.
|
||||
readDisk: () =>
|
||||
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
||||
allowUpstream: () => _overpassRateLimiter(admission.key),
|
||||
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
|
||||
loadUpstream: async () => {
|
||||
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT)
|
||||
return {
|
||||
|
||||
@@ -122,14 +122,12 @@ function sanitizeOverpassBody(rawBody) {
|
||||
const boundedSets = new Set();
|
||||
for (let stmt of withoutTags.split(';')) {
|
||||
stmt = stmt.trim();
|
||||
// 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;
|
||||
if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue;
|
||||
|
||||
// Strip output-set assignments (NOT input bounds). With the tag filters
|
||||
// already gone, a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot
|
||||
// be misread as a spatial bound; bounds live in (...) / function calls /
|
||||
// set refs, never inside [...], so nothing real was lost.
|
||||
// Strip output-set assignments (NOT input bounds). Bracket tag filters were
|
||||
// removed above so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot be
|
||||
// misread as a spatial bound. Bounds live in (...) / function calls / set
|
||||
// refs, never inside [...], so the probe loses nothing real.
|
||||
const outSets = [];
|
||||
const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
|
||||
outSets.push(name);
|
||||
@@ -168,10 +166,8 @@ function sanitizeOverpassBody(rawBody) {
|
||||
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(
|
||||
/\[\s*timeout\s*:\s*(\d+)\s*\]/gi,
|
||||
/\[timeout:\s*(\d+)\s*\]/gi,
|
||||
(_, n) =>
|
||||
`[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`,
|
||||
);
|
||||
|
||||
@@ -3,24 +3,10 @@ import assert from 'node:assert/strict';
|
||||
import { Readable } from 'node:stream';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { overpassProxy } from '../overpass.js';
|
||||
import { _overpassCache } from './cache.js';
|
||||
import { sanitizeOverpassBody } from './query.js';
|
||||
|
||||
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', () => {
|
||||
for (const query of [
|
||||
'node[',
|
||||
@@ -51,15 +37,6 @@ test('bounded queries preserve quoted brackets, comments, multiple tags and time
|
||||
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', () => {
|
||||
@@ -149,13 +126,7 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
{ 'sec-fetch-site': 'cross-site' },
|
||||
{ 'sec-fetch-site': 'same-site' },
|
||||
{ origin: 'null' },
|
||||
// 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',
|
||||
},
|
||||
{ 'x-forwarded-for': '127.0.0.1' },
|
||||
]) {
|
||||
const response = await call(route, { headers, forbidRead: true });
|
||||
assert.equal(response.statusCode, 403);
|
||||
@@ -165,24 +136,12 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
});
|
||||
}
|
||||
|
||||
test('same-origin, LAN, proxied and non-browser requests still reach validation; GET status stays available', async (t) => {
|
||||
freshClock(t);
|
||||
test('same-origin, LAN and non-browser requests still reach validation; GET status stays available', async () => {
|
||||
const route = handler();
|
||||
for (const headers of [
|
||||
{},
|
||||
{ origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' },
|
||||
{ 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 });
|
||||
assert.equal(response.statusCode, 400);
|
||||
@@ -199,7 +158,8 @@ test('same-origin, LAN, proxied and non-browser requests still reach validation;
|
||||
});
|
||||
|
||||
test('invalid requests spend admission quota before parsing, including on an unconfigured server', async (t) => {
|
||||
const clock = freshClock(t);
|
||||
let now = Date.now() + 120000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
@@ -213,216 +173,6 @@ test('invalid requests spend admission quota before parsing, including on an unc
|
||||
assert.equal(denied.reads, 0);
|
||||
assert.equal(denied.headers['retry-after'], '60');
|
||||
assert.equal((await call(route, { peer: 'other-peer' })).statusCode, 400);
|
||||
clock.now += 60_001;
|
||||
now += 60001;
|
||||
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');
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
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,7 +5,6 @@ import { createBrowserViteConfig } from '../../build/vite.js';
|
||||
import { localProviderPlugins } from '../providers/local.js';
|
||||
import { localMcpPlugin } from '../mcp/plugin.js';
|
||||
import { apiNotFoundPlugin } from './api-not-found.js';
|
||||
import { conformanceReferencesPlugin } from './conformance-references.js';
|
||||
import { googleTokenUnavailablePlugin } from './google-token-unavailable.js';
|
||||
import { standaloneVoiceTools } from './voiceTools.js';
|
||||
import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js';
|
||||
@@ -31,7 +30,6 @@ export function standaloneConfig(
|
||||
}),
|
||||
localMcpPlugin(),
|
||||
googleTokenUnavailablePlugin(),
|
||||
conformanceReferencesPlugin(),
|
||||
apiNotFoundPlugin(),
|
||||
unwatchedRuntimeDirsPlugin(),
|
||||
],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { toLonLatH } from '../geo/index.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
feedProvenanceEnvelope,
|
||||
layerSnapshot,
|
||||
@@ -109,7 +109,9 @@ export function getWorldPacket(options = {}) {
|
||||
if (!dataManager || typeof dataManager.getAll !== 'function')
|
||||
throw new TypeError('World Packet requires a data manager');
|
||||
|
||||
const eye = toLonLatH(viewer.camera.positionWC);
|
||||
const cartographic = Cesium.Cartographic.fromCartesian(
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
// Read the roster once. Voice must not rewalk disabled layers: preserving the
|
||||
// current enabled-only view keeps the model's context budget bounded.
|
||||
const rows = dataManager.getAll();
|
||||
@@ -124,9 +126,9 @@ export function getWorldPacket(options = {}) {
|
||||
ok: true,
|
||||
action: 'get_current_view_state',
|
||||
camera: {
|
||||
latitude: eye.lat,
|
||||
longitude: eye.lon,
|
||||
heightM: eye.height,
|
||||
latitude: Cesium.Math.toDegrees(cartographic.latitude),
|
||||
longitude: Cesium.Math.toDegrees(cartographic.longitude),
|
||||
heightM: cartographic.height,
|
||||
},
|
||||
style: styleManager?.activeStyle || 'normal',
|
||||
context: contextState
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { isUnavailableCapability } from '../sources/capability.js';
|
||||
import { defaultGeospatial } from '../search/defaults.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { cameraOf } from '../render/cesium/camera.js';
|
||||
import { greatCircleMeters } from '../geoDistance.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
@@ -1701,18 +1700,14 @@ export function createAnnotationEngine({
|
||||
Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0),
|
||||
);
|
||||
const sphere = Cesium.BoundingSphere.fromPoints(cart);
|
||||
const camera = cameraOf(viewer);
|
||||
camera.flyToBounds(
|
||||
{ center: sphere.center, radius: sphere.radius },
|
||||
{
|
||||
offset: {
|
||||
heading: camera.pose().heading,
|
||||
pitch: Cesium.Math.toRadians(-35),
|
||||
range: Math.max(900, sphere.radius * 2.6),
|
||||
},
|
||||
durationS: 1.6,
|
||||
},
|
||||
);
|
||||
viewer.camera.flyToBoundingSphere(sphere, {
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
viewer.camera.heading,
|
||||
Cesium.Math.toRadians(-35),
|
||||
Math.max(900, sphere.radius * 2.6),
|
||||
),
|
||||
duration: 1.6,
|
||||
});
|
||||
} catch {
|
||||
/* camera assist is best-effort */
|
||||
}
|
||||
@@ -1762,23 +1757,22 @@ export function createAnnotationEngine({
|
||||
: anno.ring,
|
||||
)
|
||||
: viewportRange(anno.viewport) || 600;
|
||||
const camera = cameraOf(viewer);
|
||||
camera.flyToBounds(
|
||||
viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
target.lon,
|
||||
target.lat,
|
||||
anno.anchor.height || 0,
|
||||
),
|
||||
range,
|
||||
),
|
||||
{
|
||||
center: {
|
||||
lon: target.lon,
|
||||
lat: target.lat,
|
||||
height: anno.anchor.height || 0,
|
||||
},
|
||||
radius: range,
|
||||
},
|
||||
{
|
||||
offset: {
|
||||
heading: camera.pose().heading,
|
||||
pitch: Cesium.Math.toRadians(-35),
|
||||
range: range * 2.4,
|
||||
},
|
||||
durationS: 1.8,
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
viewer.camera.heading,
|
||||
Cesium.Math.toRadians(-35),
|
||||
range * 2.4,
|
||||
),
|
||||
duration: 1.8,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
@@ -1984,17 +1978,15 @@ export function createAnnotationEngine({
|
||||
}) {
|
||||
if (destroyed) return;
|
||||
try {
|
||||
cameraOf(viewer).flyTo(
|
||||
{ lon, lat, height },
|
||||
{
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(heading),
|
||||
pitch: Cesium.Math.toRadians(pitch),
|
||||
roll: 0,
|
||||
},
|
||||
durationS: duration,
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(lon, lat, height),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(heading),
|
||||
pitch: Cesium.Math.toRadians(pitch),
|
||||
roll: 0,
|
||||
},
|
||||
);
|
||||
duration,
|
||||
});
|
||||
} catch {
|
||||
/* camera flight best-effort */
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
|
||||
// Voice-annotation resilience contract tests — pure logic, no network, no browser.
|
||||
//
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
installRenderGovernor,
|
||||
_resetRenderGovernorForTest,
|
||||
} from '../renderGovernor.js';
|
||||
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
|
||||
|
||||
// A resolver that replays a scripted sequence of outcomes (undefined = transient,
|
||||
// null = definitive miss, object = footprint) and counts its invocations.
|
||||
@@ -1263,7 +1261,7 @@ test('hand-drawn marks: one mark can be hidden, renamed, recoloured, framed and
|
||||
const { renderer, calls } = fakeRenderer();
|
||||
const flights = [];
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: bindFakeCesiumCamera({ camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } }),
|
||||
viewer: { camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } },
|
||||
renderer,
|
||||
});
|
||||
let changes = 0;
|
||||
@@ -1332,12 +1330,12 @@ test('hand-drawn marks: remove, hide, clear, expiry and framing have their effec
|
||||
const { renderer, calls } = fakeRenderer();
|
||||
const flights = [];
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: bindFakeCesiumCamera({
|
||||
viewer: {
|
||||
camera: {
|
||||
heading: 0,
|
||||
flyToBoundingSphere: (sphere) => flights.push(sphere),
|
||||
},
|
||||
}),
|
||||
},
|
||||
renderer,
|
||||
});
|
||||
let changes = 0;
|
||||
@@ -1549,7 +1547,7 @@ test('hand-drawn marks: with every mark hidden the scene idles, and showing one
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
let renders = 0;
|
||||
installRenderGovernor(framePortFor({ requestRenderMode: true, requestRender: () => (renders += 1) }));
|
||||
installRenderGovernor({ scene: { requestRenderMode: true, requestRender: () => (renders += 1) } });
|
||||
const { renderer } = fakeRenderer();
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
await engine.annotate([
|
||||
|
||||
+62
-32
@@ -18,9 +18,9 @@
|
||||
* exceptions inside each.
|
||||
* - 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
|
||||
* layer and therefore outside the shared claim. Both are suppressed for the
|
||||
* layer and therefore outside the shared claim. Both are borrowed for the
|
||||
* session and given back on the way out, and `destroy()` gives back every
|
||||
* listener, the pointer subscriptions, the preview data source and the window
|
||||
* listener, the Cesium handler, the preview data source and the window
|
||||
* handle, so the tool can be turned off or the shell disposed without leaving
|
||||
* anything behind.
|
||||
*
|
||||
@@ -37,8 +37,6 @@ import {
|
||||
pointerOwner,
|
||||
releasePointer,
|
||||
} from '../data/inputOwnership.js';
|
||||
import { inputFor } from '../input/dispatcher.js';
|
||||
import { sensorPickPosition } from '../input/sensorPick.js';
|
||||
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
|
||||
import {
|
||||
DRAW_SHAPES,
|
||||
@@ -90,8 +88,10 @@ export function initDrawTool({
|
||||
let shape = 'area';
|
||||
let color = 'primary';
|
||||
let hint = '';
|
||||
let unbindScene = null;
|
||||
let handler = null;
|
||||
let lease = null;
|
||||
let savedDoubleClick = null;
|
||||
let savedSingleClick = null;
|
||||
let cursor = null; // last mouse position on the canvas, for the rubber band
|
||||
// Bumped by anything that supersedes an in-flight finish: cancelling, clearing
|
||||
// the board, leaving draw mode, teardown. An `annotate()` that resolves after
|
||||
@@ -228,9 +228,7 @@ export function initDrawTool({
|
||||
};
|
||||
const onMove = (event) => {
|
||||
if (!session || session.shape === 'pin' || destroyed) return;
|
||||
// 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));
|
||||
const p = worldAt(event.endPosition);
|
||||
cursor = p
|
||||
? Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0)
|
||||
: null;
|
||||
@@ -386,29 +384,50 @@ export function initDrawTool({
|
||||
}
|
||||
|
||||
function bindSceneHandler() {
|
||||
if (unbindScene) return;
|
||||
const input = inputFor(viewer);
|
||||
const held = { lease: () => lease };
|
||||
const offs = [
|
||||
input.onPointer('click', onClick, held),
|
||||
input.onPointer('move', onMove, held),
|
||||
input.onPointer('dblclick', () => void finish(), held),
|
||||
// Cesium's Viewer binds BOTH stock click actions on its own handler: the
|
||||
// single click picks an entity into `viewer.selectedEntity`, the double
|
||||
// click tracks it. Neither goes through a layer, so neither yields to the
|
||||
// shared pointer claim — they are suppressed for the session and given
|
||||
// back on the way out. Suppressing the single click is what stops a
|
||||
// vertex placed on a contact from also selecting it.
|
||||
input.suppressStockClicks(),
|
||||
];
|
||||
unbindScene = () => offs.forEach((off) => off());
|
||||
if (handler) return;
|
||||
handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
handler.setInputAction(onClick, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
handler.setInputAction(onMove, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
||||
handler.setInputAction(() => {
|
||||
void finish();
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
|
||||
// Cesium's Viewer binds BOTH stock click actions on its own handler: the
|
||||
// single click picks an entity into `viewer.selectedEntity`, the double
|
||||
// click tracks it. Neither goes through a layer, so neither can be fixed by
|
||||
// the shared pointer claim — they have to be borrowed outright for the
|
||||
// session and given back on the way out. Borrowing the single click is what
|
||||
// stops a vertex placed on a contact from also selecting it.
|
||||
const stock = viewer.screenSpaceEventHandler;
|
||||
savedSingleClick =
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null;
|
||||
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);
|
||||
}
|
||||
|
||||
function releaseSceneHandler() {
|
||||
unbindScene?.();
|
||||
unbindScene = null;
|
||||
// Drop the keydown listener bound alongside the pointer subscriptions.
|
||||
if (handler) {
|
||||
handler.destroy();
|
||||
handler = null;
|
||||
}
|
||||
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) {
|
||||
const [target, type, listener, options] = domListeners[i];
|
||||
if (listener !== onKey) continue;
|
||||
@@ -504,15 +523,26 @@ export function initDrawTool({
|
||||
return {
|
||||
active,
|
||||
destroyed,
|
||||
sceneHandler: Boolean(unbindScene),
|
||||
sceneHandler: Boolean(handler),
|
||||
domListeners: domListeners.length,
|
||||
previewDataSources: countPreviewDataSources(viewer),
|
||||
previewEntities: destroyed ? 0 : dataSource.entities.values.length,
|
||||
pointerOwner: pointerOwner(),
|
||||
// The viewer's own select and follow, which the input port runs: off
|
||||
// while a session is open, back on after it closes.
|
||||
stockSingleClick: inputFor(viewer).stockEnabled('select'),
|
||||
stockDoubleClick: inputFor(viewer).stockEnabled('dblclickFollow'),
|
||||
// The viewer's OWN click actions. While a session is open both are
|
||||
// borrowed (absent here); after it closes both are back. A harness can
|
||||
// compare the restored functions by identity with what it captured
|
||||
// before, which is the only way to prove they were given back rather
|
||||
// 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. */
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
releasePointer,
|
||||
resetPointerOwnership,
|
||||
} from '../data/inputOwnership.js';
|
||||
import { fakeInput } from '../input/testDoubles.mjs';
|
||||
|
||||
/* ── DOM doubles ───────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -224,13 +223,10 @@ function fakeAnnotations() {
|
||||
}
|
||||
|
||||
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
|
||||
function harness({ navigation = null, beforeInput = () => {} } = {}) {
|
||||
function harness({ navigation = null } = {}) {
|
||||
resetPointerOwnership();
|
||||
const { byId, documentDouble } = fakeDom();
|
||||
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 previousDocument = globalThis.document;
|
||||
const previousWindow = globalThis.window;
|
||||
@@ -260,9 +256,7 @@ function harness({ navigation = null, beforeInput = () => {} } = {}) {
|
||||
controls,
|
||||
/** A control's change, as the view sends it. */
|
||||
act: (name, ...args) => controls.actions?.[name]?.(...args),
|
||||
input,
|
||||
restore() {
|
||||
input.destroy();
|
||||
resetPointerOwnership();
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = previousDocument;
|
||||
@@ -341,33 +335,30 @@ test('a destroyed instance cannot free the pointer its replacement holds', async
|
||||
}
|
||||
});
|
||||
|
||||
test('both stock viewer click actions are held off for the session and given back', async () => {
|
||||
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);
|
||||
},
|
||||
});
|
||||
test('both stock viewer click actions are borrowed for the session and given back', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
assert.equal(h.input.stockEnabled('select'), true);
|
||||
const stockClick = () => 'viewer picks and selects';
|
||||
const stockDouble = () => 'viewer tracks';
|
||||
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
|
||||
h.viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
|
||||
|
||||
h.act('toggleDraw');
|
||||
assert.equal(
|
||||
h.input.stockEnabled('select'),
|
||||
false,
|
||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
|
||||
undefined,
|
||||
"the viewer's own selecting click must not run while drawing",
|
||||
);
|
||||
assert.equal(h.input.stockEnabled('dblclickFollow'), false);
|
||||
assert.equal(
|
||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK),
|
||||
undefined,
|
||||
);
|
||||
|
||||
h.act('toggleDraw');
|
||||
assert.equal(h.input.stockEnabled('select'), true);
|
||||
assert.equal(h.input.stockEnabled('dblclickFollow'), true);
|
||||
h.input.destroy();
|
||||
assert.equal(
|
||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
|
||||
stockClick,
|
||||
'the exact function goes back to the viewer, not a replacement',
|
||||
'the exact function is restored, not a replacement',
|
||||
);
|
||||
assert.equal(
|
||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK),
|
||||
@@ -477,7 +468,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
|
||||
assert.equal(isPointerFree(), true, 'the pointer is free');
|
||||
const after = h.tool.diagnostics();
|
||||
assert.equal(after.destroyed, true);
|
||||
assert.equal(after.sceneHandler, false, 'the pointer subscriptions are gone');
|
||||
assert.equal(after.sceneHandler, false, 'the Cesium handler is gone');
|
||||
assert.equal(after.domListeners, 0, 'every DOM listener came off');
|
||||
assert.equal(h.documentDouble.listeners.length, 0, 'including the keydown');
|
||||
assert.equal(h.controls.actions, null, 'and the controls are disconnected');
|
||||
|
||||
@@ -5,7 +5,6 @@ import { createPinia } from 'pinia';
|
||||
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
||||
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.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. */
|
||||
function hybridWithBoard(viewer) {
|
||||
@@ -111,7 +110,7 @@ function fakeViewer(lon, lat) {
|
||||
return {
|
||||
dataSources,
|
||||
frames,
|
||||
viewer: bindFakeCesiumCredits({
|
||||
viewer: {
|
||||
credits,
|
||||
creditDisplay: {
|
||||
addStaticCredit: (credit) => credits.add(credit),
|
||||
@@ -131,7 +130,7 @@ function fakeViewer(lon, lat) {
|
||||
return index >= 0;
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -541,9 +540,8 @@ test('buildings share one probe per frame and preserve probe geometry, filtering
|
||||
const detail = parent.detail;
|
||||
assert.equal(detail.index, index);
|
||||
assert.equal(detail.status, trace === firstTrace ? 'recorded' : 'missing');
|
||||
// The fourth probe finds nothing and the fifth throws: both are misses.
|
||||
assert.equal(detail.clampResult,
|
||||
trace === firstTrace && index !== 3 && index !== 4 ? 'hit' : 'missed');
|
||||
trace === firstTrace && index === 4 ? 'threw' : 'returned');
|
||||
assert.ok(Number.isFinite(detail.clampStart) && Number.isFinite(detail.clampEnd));
|
||||
assert.ok(parent.start <= detail.clampStart && detail.clampStart <= detail.clampEnd && detail.clampEnd <= parent.end);
|
||||
if (index === 7) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
import { polygonsContain } from '../data/adminBoundaries.js';
|
||||
import { streetFootprint } from './outlineRungs.js';
|
||||
import { bindFakeCesiumCredits } from '../testSupport/fakeCreditsPort.mjs';
|
||||
|
||||
function viewerAt(lat, lon, height = 20_000) {
|
||||
return {
|
||||
@@ -181,10 +180,10 @@ test('a geocoded city or neighborhood upgrades to the bundled outline', async ()
|
||||
const credits = [];
|
||||
const cancelled = await city.resolve(
|
||||
'Austin',
|
||||
bindFakeCesiumCredits({
|
||||
{
|
||||
...viewerAt(39, -100, 5_000_000),
|
||||
creditDisplay: { addStaticCredit: (credit) => credits.push(credit) },
|
||||
}),
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const pending = cancelled.resolveOutline();
|
||||
|
||||
+18
-11
@@ -45,7 +45,6 @@ import {
|
||||
import { createOutlineRungs } from './outlineRungs.js';
|
||||
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
import { heightsFor } from '../queries/heights.js';
|
||||
|
||||
/** Prepare only local dateline outlines for planar admission and centering. */
|
||||
function localOutlineFrame(fp) {
|
||||
@@ -2352,18 +2351,26 @@ export function createAnnotationResolver({
|
||||
function sampleGroundHeight(viewer, lon, lat) {
|
||||
const scene = viewer?.scene;
|
||||
if (!scene) return 0;
|
||||
const heights = heightsFor(scene);
|
||||
// A miss is tiles not ready: fall through to the terrain, then 0.
|
||||
if (heights.supported().clamp) {
|
||||
const surface = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
|
||||
const clamped = heights.clampNow(surface);
|
||||
if (clamped) {
|
||||
const h = Cesium.Cartographic.fromCartesian(clamped)?.height;
|
||||
if (Number.isFinite(h)) return h;
|
||||
try {
|
||||
if (
|
||||
scene.clampToHeightSupported &&
|
||||
typeof scene.clampToHeight === 'function'
|
||||
) {
|
||||
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
||||
const surface = Cesium.Cartographic.toCartesian(carto);
|
||||
const clamped = scene.clampToHeight(surface);
|
||||
if (clamped) {
|
||||
const h = Cesium.Cartographic.fromCartesian(clamped).height;
|
||||
if (Number.isFinite(h)) return h;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* tiles not ready — fall through */
|
||||
}
|
||||
const globeHeight = heights.now({ lon, lat }, { against: 'terrain' });
|
||||
return globeHeight !== null && globeHeight > 0 ? globeHeight : 0;
|
||||
const globeHeight = scene.globe?.getHeight?.(
|
||||
Cesium.Cartographic.fromDegrees(lon, lat),
|
||||
);
|
||||
return Number.isFinite(globeHeight) && globeHeight > 0 ? globeHeight : 0;
|
||||
}
|
||||
|
||||
function shortLabel(formattedAddress) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { followedOf } from '../data/followed.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { getOverlayPaintRect } from '../overlays/worldOverlay.js';
|
||||
import {
|
||||
@@ -9,7 +8,6 @@ import { t } from '../i18n/index.js';
|
||||
import { nextTick } from 'vue';
|
||||
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
import { heightsFor } from '../queries/heights.js';
|
||||
|
||||
/** A label whose detailed outline could not be resolved. */
|
||||
function outlineUnavailableLabel(label) {
|
||||
@@ -143,10 +141,13 @@ export function createScreenAnnotationRenderer(
|
||||
return cached.h;
|
||||
}
|
||||
try {
|
||||
const heights = heightsFor(scene);
|
||||
if (heights.supported().clamp) {
|
||||
// A miss is tiles not ready.
|
||||
const c = heights.clampNow(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
|
||||
if (
|
||||
scene.clampToHeightSupported &&
|
||||
typeof scene.clampToHeight === 'function'
|
||||
) {
|
||||
const c = scene.clampToHeight(
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat, 0),
|
||||
);
|
||||
if (c) {
|
||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
||||
if (Number.isFinite(h) && h > -430 && h < 9000) {
|
||||
@@ -421,10 +422,12 @@ export function createScreenAnnotationRenderer(
|
||||
let top = painted?.y;
|
||||
let bottom = painted ? painted.y + painted.h : undefined;
|
||||
|
||||
const followed = followedOf(viewer);
|
||||
const displayPosition = followed?.displayPosition;
|
||||
const ent = viewer.trackedEntity;
|
||||
const now = Cesium.JulianDate.now();
|
||||
const world =
|
||||
typeof displayPosition === 'function' ? displayPosition() : null;
|
||||
typeof ent?.gevDisplayPosition === 'function'
|
||||
? ent.gevDisplayPosition()
|
||||
: null;
|
||||
const win = world
|
||||
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
@@ -433,14 +436,13 @@ export function createScreenAnnotationRenderer(
|
||||
)
|
||||
: null;
|
||||
|
||||
// Billboard box — centered on the anchor, magnified by scaleByDistance, from
|
||||
// the size the tracking layer recorded for its sprite.
|
||||
const sprite = followed?.sprite;
|
||||
if (sprite && win && Number.isFinite(win.x) && Number.isFinite(win.y)) {
|
||||
const baseW = sprite.width ?? 28;
|
||||
const baseH = sprite.height ?? 28;
|
||||
// Billboard box — centered on the anchor, magnified by scaleByDistance.
|
||||
const bb = ent?.billboard;
|
||||
if (bb && win && Number.isFinite(win.x) && Number.isFinite(win.y)) {
|
||||
const baseW = bb.width?.getValue?.(now) ?? 28;
|
||||
const baseH = bb.height?.getValue?.(now) ?? 28;
|
||||
let scale = 1;
|
||||
const sbd = sprite.scaleByDistance;
|
||||
const sbd = bb.scaleByDistance?.getValue?.(now);
|
||||
if (sbd)
|
||||
scale = nearFarValue(
|
||||
sbd,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { recordWorkSlice } from '../sources/phaseTiming.js';
|
||||
import { ringCentroid } from '../sources/featureGeometry.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||
import { heightsFor } from '../queries/heights.js';
|
||||
|
||||
/**
|
||||
* World-space annotation renderer (Direction A).
|
||||
@@ -175,16 +174,20 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
const sliceStart = performance.now();
|
||||
let error;
|
||||
try {
|
||||
if (heightsFor(viewer.scene).supported().clamp) {
|
||||
if (
|
||||
viewer.scene.clampToHeightSupported &&
|
||||
typeof viewer.scene.clampToHeight === 'function'
|
||||
) {
|
||||
const [lon, lat] = task.points[task.index++];
|
||||
timing.probes += 1;
|
||||
try {
|
||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
|
||||
let c;
|
||||
note?.('clampStart');
|
||||
if (detail) detail.clampResult = 'threw';
|
||||
try {
|
||||
c = heightsFor(viewer.scene).clampNow(position);
|
||||
if (detail) detail.clampResult = c ? 'hit' : 'missed';
|
||||
c = viewer.scene.clampToHeight(position);
|
||||
if (detail) detail.clampResult = 'returned';
|
||||
} finally {
|
||||
note?.('clampEnd');
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createApplication } from './application.js';
|
||||
import { installTrackpadPinchZoom } from '../render/cesium/ctrlWheelZoom.js';
|
||||
import { installTrackpadPinchZoom } from './viewer.js';
|
||||
|
||||
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
|
||||
function fixture(overrides = {}) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolveCatalogSources } from './sourceComposition.js';
|
||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||
import { worldClock } from '../time/worldClock.js';
|
||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||
import { createWindLayer } from '../layers/wind/index.js';
|
||||
@@ -29,7 +30,6 @@ import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
||||
import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
||||
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
|
||||
import { createApplicationCables } from './layers/submarineCables.js';
|
||||
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
|
||||
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
||||
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
||||
import { localGeoJsonServices } from './localGeojsonServices.js';
|
||||
@@ -73,7 +73,7 @@ export function createApplicationCatalog({
|
||||
const sourceComposition = resolveCatalogSources(sources);
|
||||
sources = sourceComposition.sources;
|
||||
const militaryRegistry = createMilitaryRegistry();
|
||||
const weatherClock = createWeatherClock();
|
||||
const weatherClock = createWeatherClock({ world: worldClock });
|
||||
const dispose = () => {
|
||||
signal.removeEventListener('abort', dispose);
|
||||
militaryRegistry.dispose();
|
||||
@@ -170,7 +170,6 @@ export function createApplicationCatalog({
|
||||
clock: weatherClock,
|
||||
}),
|
||||
createCyclonesLayer({ feed: sources.cyclones }),
|
||||
...createOsmOverlayLayers(),
|
||||
...createInfrastructureLayers(localGeoJsonServices),
|
||||
createApplicationCables({ source: sources.cables }),
|
||||
createApplicationFirms({
|
||||
|
||||
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
signal: b.signal,
|
||||
surface: fixtureSurface(b.signal),
|
||||
});
|
||||
assert.equal(first.layers.length, 35);
|
||||
assert.equal(first.layers.length, 33);
|
||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||
assert.deepEqual(
|
||||
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),
|
||||
};
|
||||
const catalog = createApplicationCatalog(options);
|
||||
assert.equal(catalog.layers.length, 35);
|
||||
assert.equal(catalog.layers.length, 33);
|
||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||
assert.equal(
|
||||
catalogControlServices(catalog).streetLevelLayer,
|
||||
|
||||
+14
-13
@@ -13,7 +13,6 @@
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { announceNavigationAuthority } from '../navigationPolicy.js';
|
||||
import { cameraOf } from '../render/cesium/camera.js';
|
||||
import { annotationsFromParams, createView } from '../view/index.js';
|
||||
|
||||
const EMBED_VIEW_MESSAGE = 'gev:view';
|
||||
@@ -148,19 +147,21 @@ const delay = (ms, signal) =>
|
||||
function flyTo(viewer, camera) {
|
||||
announceNavigationAuthority('embed-view');
|
||||
return new Promise((resolve) => {
|
||||
cameraOf(viewer).flyTo(
|
||||
{ lon: camera.lon, lat: camera.lat, height: camera.altitude_m },
|
||||
{
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(camera.heading_deg),
|
||||
pitch: Cesium.Math.toRadians(camera.pitch_deg),
|
||||
roll: 0,
|
||||
},
|
||||
durationS: FLIGHT_SECONDS,
|
||||
onComplete: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
camera.lon,
|
||||
camera.lat,
|
||||
camera.altitude_m,
|
||||
),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(camera.heading_deg),
|
||||
pitch: Cesium.Math.toRadians(camera.pitch_deg),
|
||||
roll: 0,
|
||||
},
|
||||
);
|
||||
duration: FLIGHT_SECONDS,
|
||||
complete: () => resolve(true),
|
||||
cancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
@@ -13,7 +12,7 @@ import { createView } from '../view/index.js';
|
||||
|
||||
const fakeViewer = () => {
|
||||
const flights = [];
|
||||
return bindFakeCesiumCamera({
|
||||
return {
|
||||
flights,
|
||||
camera: {
|
||||
flyTo(options) {
|
||||
@@ -21,7 +20,7 @@ const fakeViewer = () => {
|
||||
options.complete();
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
const fakeLayers = (enabled) => ({
|
||||
getAll: () =>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createFirmsHeatmapLayer as createLayer } from '../../layers/firms/index.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
@@ -28,5 +29,8 @@ export function createApplicationFirms({ surface, ...options }) {
|
||||
clearSource: overlays.clearOverlaySource,
|
||||
hitTest: overlays.hitTestWorldOverlay,
|
||||
},
|
||||
screenSpaceEventHandlerFactory:
|
||||
options.screenSpaceEventHandlerFactory ??
|
||||
((viewer) => new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)),
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/** Wire the S2 Underground GhostMaps layer into the application catalog. */
|
||||
export function createApplicationGhostMaps(options) {
|
||||
@@ -12,7 +14,10 @@ export function createApplicationGhostMaps(options) {
|
||||
hitTest: overlays.hitTestWorldOverlay,
|
||||
},
|
||||
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
||||
screenSpaceEventHandlerFactory: (viewer) =>
|
||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
||||
picking,
|
||||
pointer: { isPointerFree },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
|
||||
export function createApplicationGnssIntegrity(options) {
|
||||
return createGnssIntegrityLayer(options);
|
||||
return createGnssIntegrityLayer({
|
||||
worldNow: worldTimeFor('gnss-integrity'),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createFirePerimetersLayer,
|
||||
createInciwebIndexSource,
|
||||
@@ -5,6 +6,7 @@ import {
|
||||
} from '../../layers/perimeters/index.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/** Wire WFIGS fire perimeters into the application catalog. */
|
||||
export function createApplicationFirePerimeters(options) {
|
||||
@@ -18,7 +20,10 @@ export function createApplicationFirePerimeters(options) {
|
||||
inciwebSource: createInciwebIndexSource(),
|
||||
inciwebPublications: createInciwebPublicationSource(),
|
||||
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
||||
screenSpaceEventHandlerFactory: (viewer) =>
|
||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
||||
picking,
|
||||
pointer: { isPointerFree },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
|
||||
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
|
||||
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
|
||||
import { governorRequestRender } from '../../renderGovernor.js';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
const imageryTime = worldTimeFor('recent-imagery');
|
||||
|
||||
/** Construct imagery presentation over one explicitly supplied acquisition source. */
|
||||
export function createApplicationRecentImagery({ source }) {
|
||||
@@ -15,5 +18,6 @@ export function createApplicationRecentImagery({ source }) {
|
||||
thumbnails: createThumbnailLoader({
|
||||
requestThumbnail: (...args) => source.getThumbnail(...args),
|
||||
}),
|
||||
now: () => new Date(imageryTime()),
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { createKartaViewProvider } from '../../layers/streetLevel/providers/kart
|
||||
import { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as input from '../../data/inputOwnership.js';
|
||||
import * as scenePick from '../../data/scenePick.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
|
||||
@@ -36,6 +37,6 @@ export function createApplicationStreetLevel({
|
||||
}) {
|
||||
return createStreetLevelLayer({
|
||||
providers,
|
||||
services: { sprites, picking, scenePick, render },
|
||||
services: { sprites, picking, input, scenePick, render },
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createSubmarineCableLayer } from '../../layers/submarineCables/index.js';
|
||||
import { overlayHost } from './overlayHost.js';
|
||||
/** Wire cable source geometry to the application overlay host. */
|
||||
export function createApplicationCables(options) {
|
||||
return createSubmarineCableLayer({
|
||||
overlayHost,
|
||||
screenSpaceEventHandlerFactory: (canvas) =>
|
||||
new Cesium.ScreenSpaceEventHandler(canvas),
|
||||
mapStackEventTarget: typeof window !== 'undefined' ? window : null,
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
governorRequestRender,
|
||||
getRenderGovernorDiagnostics,
|
||||
} from '../renderGovernor.js';
|
||||
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
|
||||
|
||||
test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => {
|
||||
let resolveProvider;
|
||||
@@ -45,11 +44,11 @@ test('uninstall only releases the current viewer and makes later render requests
|
||||
},
|
||||
},
|
||||
};
|
||||
installRenderGovernor(framePortFor(viewer.scene));
|
||||
installRenderGovernor(viewer);
|
||||
holdContinuousRender('fixture');
|
||||
uninstallRenderGovernor(framePortFor({}));
|
||||
uninstallRenderGovernor({});
|
||||
assert.equal(getRenderGovernorDiagnostics().installed, true);
|
||||
uninstallRenderGovernor(framePortFor(viewer.scene));
|
||||
uninstallRenderGovernor(viewer);
|
||||
const before = renders;
|
||||
governorRequestRender('late');
|
||||
assert.equal(renders, before);
|
||||
|
||||
+24
-53
@@ -1,32 +1,25 @@
|
||||
import { createApplicationOperations } from './operations.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
cesiumRenderer,
|
||||
cesiumViewerOf,
|
||||
} from '../render/cesium/appRenderer.js';
|
||||
createApplicationViewer,
|
||||
installTrackpadPinchZoom,
|
||||
} from '../app/viewer.js';
|
||||
import { registerDataCredits } from '../data/dataCredits.js';
|
||||
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
|
||||
import { MapStackController } from '../mapStackController.js';
|
||||
import { loadPhotorealisticTileset } from '../mapStartup.js';
|
||||
import { initLogoGaze } from '../logoGaze.js';
|
||||
import { inputFor } from '../input/dispatcher.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
installRenderGovernor,
|
||||
uninstallRenderGovernor,
|
||||
governorRequestRender,
|
||||
} from '../renderGovernor.js';
|
||||
import { describeError } from './errors.js';
|
||||
import { worldClock, worldTimeFor } from '../time/worldClock.js';
|
||||
import { followWorldTime } from '../time/viewerClock.js';
|
||||
import { onLocaleChange, t } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Construct the application globe using the caller's local configuration. */
|
||||
export async function createApplicationScene({
|
||||
renderer: rendererPlugin = cesiumRenderer,
|
||||
requestServices,
|
||||
googleApiKey,
|
||||
googleTokens = null,
|
||||
@@ -53,41 +46,26 @@ export async function createApplicationScene({
|
||||
});
|
||||
}
|
||||
loaderStatus.textContent = t('Configuring viewer...');
|
||||
// The scene still loads the tiles, so their credentials stay here.
|
||||
const renderer = await rendererPlugin.create({
|
||||
container: document.getElementById('cesiumContainer'),
|
||||
signal,
|
||||
// Provider attribution stays visible, including clean-view and recording.
|
||||
const creditContainer = document.createElement('div');
|
||||
creditContainer.id = 'cesium-credits';
|
||||
document.body.appendChild(creditContainer);
|
||||
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(() => {
|
||||
try {
|
||||
uninstallRenderGovernor(renderer.frame);
|
||||
} finally {
|
||||
renderer.destroy();
|
||||
}
|
||||
uninstallRenderGovernor(viewer);
|
||||
if (!viewer.isDestroyed()) viewer.destroy();
|
||||
});
|
||||
// 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;
|
||||
defer(installTrackpadPinchZoom(viewer));
|
||||
defer(
|
||||
followWorldTime(viewer, worldTimeFor('sky'), {
|
||||
subscribe: worldClock.subscribe,
|
||||
requestRender: () => governorRequestRender('world-time'),
|
||||
}),
|
||||
);
|
||||
registerDataCredits(viewer, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
||||
@@ -158,12 +136,5 @@ export async function createApplicationScene({
|
||||
});
|
||||
|
||||
signal.throwIfAborted();
|
||||
return {
|
||||
renderer,
|
||||
viewer,
|
||||
tileset,
|
||||
mapStackController,
|
||||
operations,
|
||||
input,
|
||||
};
|
||||
return { viewer, tileset, mapStackController, operations };
|
||||
}
|
||||
@@ -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) => {
|
||||
const actual = catalog({}, t);
|
||||
assert.equal(actual.layers.length, 35);
|
||||
assert.equal(actual.layers.length, 33);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
|
||||
@@ -1,68 +1,25 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { flyToAustin } from '../camera.js';
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
|
||||
test('teardown before the initial camera delay prevents a late flight', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let flights = 0;
|
||||
let cancelled = 0;
|
||||
const stop = flyToAustin(
|
||||
bindFakeCesiumCamera({
|
||||
isDestroyed: () => false,
|
||||
camera: {
|
||||
setView() {},
|
||||
flyTo() {
|
||||
flights++;
|
||||
},
|
||||
cancelFlight() {
|
||||
cancelled++;
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
stop();
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(flights, 0);
|
||||
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({
|
||||
const stop = flyToAustin({
|
||||
isDestroyed: () => false,
|
||||
camera: {
|
||||
get _currentFlight() {
|
||||
return current;
|
||||
},
|
||||
setView() {},
|
||||
flyTo(options) {
|
||||
current?.cancel();
|
||||
current = {
|
||||
name: options.duration === 4 ? 'fly-in' : 'user',
|
||||
...options,
|
||||
};
|
||||
flyTo() {
|
||||
flights++;
|
||||
},
|
||||
cancelFlight() {
|
||||
cancelled.push(current?.name);
|
||||
const flight = current;
|
||||
current = null;
|
||||
flight?.cancel();
|
||||
cancelled++;
|
||||
},
|
||||
},
|
||||
});
|
||||
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');
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(flights, 0);
|
||||
assert.equal(cancelled, 1);
|
||||
});
|
||||
+14
-15
@@ -1,4 +1,3 @@
|
||||
import { onFollowChanged } from '../data/followed.js';
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
import { initAnnotations } from '../annotations/index.js';
|
||||
import { initDrawTool } from '../annotations/drawTool.js';
|
||||
@@ -12,13 +11,12 @@ import { installViews, isEmbeddedInline } from './embed.js';
|
||||
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
|
||||
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
|
||||
import {
|
||||
installRenderGovernor,
|
||||
getRenderGovernorDiagnostics,
|
||||
governorRequestRender,
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
} 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. */
|
||||
export function createApplicationTools({
|
||||
@@ -34,11 +32,11 @@ export function createApplicationTools({
|
||||
signal,
|
||||
defer,
|
||||
}) {
|
||||
const { viewer, renderer, tileset, mapStackController, operations } = scene;
|
||||
const { viewer, tileset, mapStackController, operations } = scene;
|
||||
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
|
||||
const { dataManager } = data;
|
||||
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
|
||||
dataPacks: { overlayHost, ...sceneDataPacks },
|
||||
dataPacks: sceneDataPacks,
|
||||
isMapStackAvailable: (id) =>
|
||||
mapStackController?.isStackAvailable(id) === true,
|
||||
});
|
||||
@@ -127,10 +125,10 @@ export function createApplicationTools({
|
||||
}
|
||||
if (startChrome)
|
||||
defer(startChrome({ loadingScreen, styleManager, dataManager, signal }));
|
||||
// Idle render governor: the loop draws on demand whenever nothing animates
|
||||
// per frame. It goes idle only now, after every module above has had its
|
||||
// chance to hold. (perf wave 2)
|
||||
releaseContinuousRender('boot');
|
||||
// Idle render governor: flips the scene into requestRenderMode whenever
|
||||
// nothing animates per frame. Installed AFTER every module above has had
|
||||
// its chance to register pre-install holds. (perf wave 2)
|
||||
installRenderGovernor(viewer);
|
||||
// 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.
|
||||
defer(
|
||||
@@ -147,10 +145,12 @@ export function createApplicationTools({
|
||||
|
||||
// The follow camera recomputes the tracked target's dead-reckon position
|
||||
// every frame — tracking anything is a per-frame animation. (perf wave 2)
|
||||
const removeTrackingListener = onFollowChanged(viewer, (followed) => {
|
||||
if (followed) holdContinuousRender('tracked-entity');
|
||||
else releaseContinuousRender('tracked-entity');
|
||||
});
|
||||
const removeTrackingListener = viewer.trackedEntityChanged.addEventListener(
|
||||
() => {
|
||||
if (viewer.trackedEntity) holdContinuousRender('tracked-entity');
|
||||
else releaseContinuousRender('tracked-entity');
|
||||
},
|
||||
);
|
||||
|
||||
// Hidden-state suspension (perf wave 2): when the window/tab is hidden,
|
||||
// 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
|
||||
// keeps drawing itself (see keepPanelRendering in embed.js).
|
||||
const hidden = document.hidden && !isEmbeddedInline();
|
||||
renderer.frame.suspend(hidden);
|
||||
viewer.useDefaultRenderLoop = !hidden;
|
||||
cockpitCloudEffects?.setSuspended?.(hidden);
|
||||
if (!hidden) {
|
||||
data.presentation.flushVisible();
|
||||
@@ -194,7 +194,6 @@ export function createApplicationTools({
|
||||
getRenderGovernorDiagnostics,
|
||||
surfaceServices: operations.surface,
|
||||
requestRender: governorRequestRender,
|
||||
entityRecords: { read: readEntityRecord, write: entityRecord },
|
||||
};
|
||||
const debug = window.__godsEyeView;
|
||||
defer(() => {
|
||||
|
||||
@@ -2,6 +2,149 @@ import * as Cesium from 'cesium';
|
||||
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
|
||||
import { StarrySky } from '../sky/starrySky.js';
|
||||
|
||||
/**
|
||||
* At the farthest a hand can zoom out, the globe is this share of the
|
||||
* screen's height across. Cesium sets no limit, so zooming on left the globe
|
||||
* a speck among the stars.
|
||||
*/
|
||||
const MIN_GLOBE_SHARE = 0.3;
|
||||
|
||||
/**
|
||||
* The farthest a hand can zoom out, in metres above the ellipsoid, for a
|
||||
* vertical field of view. It follows the screen's shape, as the app's own
|
||||
* whole-globe view does, so that view always lies within it.
|
||||
* @param {number} fovy Vertical field of view, radians.
|
||||
*/
|
||||
export function zoomOutLimit(fovy) {
|
||||
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
|
||||
const angular = Math.atan(MIN_GLOBE_SHARE * Math.tan(fovy / 2));
|
||||
return radius / Math.sin(angular) - radius;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold a hand's zoom out to `zoomOutLimit` on the globe, each frame, since the
|
||||
* field of view follows the screen's shape. The limit never sits below the
|
||||
* camera: one already past it (a flight, a screen turned) stops there rather
|
||||
* than jumping in at the next zoom. Flat maps keep Cesium's own, unlimited.
|
||||
* @returns {() => void} Stop limiting.
|
||||
*/
|
||||
export function limitZoomOut(viewer) {
|
||||
const { scene, camera } = viewer;
|
||||
return scene.preRender.addEventListener(() => {
|
||||
const controller = scene.screenSpaceCameraController;
|
||||
const fovy = camera.frustum.fovy;
|
||||
if (scene.mode !== Cesium.SceneMode.SCENE3D || !Number.isFinite(fovy)) {
|
||||
controller.maximumZoomDistance = Number.POSITIVE_INFINITY;
|
||||
return;
|
||||
}
|
||||
controller.maximumZoomDistance = Math.max(
|
||||
zoomOutLimit(fovy),
|
||||
camera.positionCartographic?.height ?? 0,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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. */
|
||||
export function applicationViewerOptions(creditContainer) {
|
||||
return {
|
||||
@@ -41,6 +184,7 @@ export function createApplicationViewer({ container, creditContainer }) {
|
||||
// required on touch-first screens. Mouse/trackpad sensitivity stays native.
|
||||
if (globalThis.matchMedia?.('(pointer: coarse)').matches)
|
||||
viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
|
||||
limitZoomOut(viewer);
|
||||
// Before any tile builds a draw command: Cesium's per-vertex model
|
||||
// atmosphere fails to LINK on Apple's Metal backend and kills the
|
||||
// render loop. See app/atmosphereCompat.js.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// A hand zooming out stops while the globe is still a fair share of the
|
||||
// screen, on any screen's shape, rather than leaving it a speck.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { limitZoomOut, zoomOutLimit } from './viewer.js';
|
||||
|
||||
// The share of the screen's height the globe spans from `altitude`.
|
||||
function globeShare(altitude, fovy) {
|
||||
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
|
||||
const angular = Math.asin(radius / (radius + altitude));
|
||||
return Math.tan(angular) / Math.tan(fovy / 2);
|
||||
}
|
||||
|
||||
test("zooming out stops with the globe 30% of the screen's height across", () => {
|
||||
for (const fovy of [Math.PI / 3, 0.4, 0.2]) {
|
||||
const limit = zoomOutLimit(fovy);
|
||||
assert.ok(Math.abs(globeShare(limit, fovy) - 0.3) < 1e-9, `fovy ${fovy}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a wide screen, whose view is narrower, may zoom out farther', () => {
|
||||
assert.ok(zoomOutLimit(0.4) > zoomOutLimit(Math.PI / 3));
|
||||
assert.ok(zoomOutLimit(Math.PI / 3) > 20_000_000, 'past the views a link opens');
|
||||
});
|
||||
|
||||
function fakeViewer({ fovy = Math.PI / 3, height = 10_000_000 } = {}) {
|
||||
const scene = {
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
preRender: new Cesium.Event(),
|
||||
screenSpaceCameraController: { maximumZoomDistance: Infinity },
|
||||
};
|
||||
const camera = {
|
||||
frustum: { fovy },
|
||||
positionCartographic: { height },
|
||||
};
|
||||
return { scene, camera };
|
||||
}
|
||||
|
||||
test('the limit follows the screen, and holds a camera already past it', () => {
|
||||
const viewer = fakeViewer();
|
||||
const controller = viewer.scene.screenSpaceCameraController;
|
||||
const stop = limitZoomOut(viewer);
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(controller.maximumZoomDistance, zoomOutLimit(Math.PI / 3));
|
||||
|
||||
// A wider screen narrows the view, and the globe may go farther.
|
||||
viewer.camera.frustum.fovy = 0.4;
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4));
|
||||
|
||||
// Flown past it, the next zoom out stops there instead of jumping in.
|
||||
viewer.camera.positionCartographic.height = zoomOutLimit(0.4) * 2;
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2);
|
||||
|
||||
stop();
|
||||
viewer.camera.positionCartographic.height = 0;
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2);
|
||||
});
|
||||
|
||||
test('flat maps keep zooming out unlimited', () => {
|
||||
const viewer = fakeViewer();
|
||||
const controller = viewer.scene.screenSpaceCameraController;
|
||||
limitZoomOut(viewer);
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.ok(Number.isFinite(controller.maximumZoomDistance));
|
||||
viewer.scene.mode = Cesium.SceneMode.SCENE2D;
|
||||
viewer.camera.frustum = {};
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(controller.maximumZoomDistance, Infinity);
|
||||
});
|
||||
+18
-16
@@ -1,34 +1,36 @@
|
||||
import { toRadians } from './geo/index.js';
|
||||
import { cameraOf } from './render/rendererOf.js';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* Set camera to Austin on load with a cinematic fly-in.
|
||||
* @returns {Function} Cancels the pending or active startup flight.
|
||||
*/
|
||||
export function flyToAustin(viewer) {
|
||||
const camera = cameraOf(viewer);
|
||||
// Start from a high altitude, then fly down
|
||||
camera.setView({
|
||||
destination: { lon: -97.7431, lat: 30.2672, height: 25000 },
|
||||
orientation: { heading: 0, pitch: toRadians(-90), roll: 0 },
|
||||
viewer.camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 25000),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(0),
|
||||
pitch: Cesium.Math.toRadians(-90),
|
||||
roll: 0.0,
|
||||
},
|
||||
});
|
||||
|
||||
// Cinematic fly-in after a brief pause
|
||||
let flight = null;
|
||||
const timer = setTimeout(() => {
|
||||
if (viewer.isDestroyed()) return;
|
||||
flight = camera.flyTo(
|
||||
{ lon: -97.7431, lat: 30.2672, height: 600 },
|
||||
{
|
||||
orientation: { heading: toRadians(15), pitch: toRadians(-30), roll: 0 },
|
||||
durationS: 4.0,
|
||||
easing: 'cubicInOut',
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 600),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(15),
|
||||
pitch: Cesium.Math.toRadians(-30),
|
||||
roll: 0.0,
|
||||
},
|
||||
);
|
||||
duration: 4.0,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
}, 500);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
// Only the fly-in: a flight the user started since is theirs.
|
||||
if (!viewer.isDestroyed()) flight?.cancel();
|
||||
if (!viewer.isDestroyed()) viewer.camera.cancelFlight();
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
|
||||
import { GLOBE_VIEW } from './locations.js';
|
||||
import { projectEarthDiscToViewport } from './celestialRing.js';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -147,13 +146,13 @@ function harness(
|
||||
roll: radians(roll),
|
||||
},
|
||||
});
|
||||
const viewer = bindFakeCesiumCamera({
|
||||
const viewer = {
|
||||
camera,
|
||||
scene,
|
||||
trackedEntity: undefined,
|
||||
trackedEntityChanged: new Cesium.Event(),
|
||||
clock: { onTick: new Cesium.Event() },
|
||||
});
|
||||
};
|
||||
const target = Cesium.Cartesian3.fromDegrees(
|
||||
longitudeDeg,
|
||||
latitude,
|
||||
@@ -289,12 +288,9 @@ test('replaced viewer releases the old tracking listener without duplicate subsc
|
||||
true,
|
||||
);
|
||||
const id = activeCameraMotionId();
|
||||
// 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);
|
||||
first.viewer.trackedEntityChanged.raiseEvent({ id: 'stale-aircraft' });
|
||||
assert.equal(activeCameraMotionId(), id);
|
||||
next.viewer.trackedEntity = { id: 'new-aircraft' };
|
||||
next.viewer.trackedEntityChanged.raiseEvent(next.viewer.trackedEntity);
|
||||
next.viewer.trackedEntityChanged.raiseEvent({ id: 'new-aircraft' });
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
assert.equal(hasRenderHold(), false);
|
||||
});
|
||||
@@ -601,7 +597,7 @@ for (const mode of ['once', 'continuous']) {
|
||||
|
||||
test('direct globe rotation refuses a tracked camera without disturbing its reference frame', (t) => {
|
||||
const { viewer, camera, target } = harness(t);
|
||||
viewer.trackedEntity = { id: 'tracked-aircraft', name: 'tracked-aircraft' };
|
||||
viewer.trackedEntity = { id: 'tracked-aircraft' };
|
||||
camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-40), 1_000));
|
||||
const before = pose(camera);
|
||||
const transform = Cesium.Matrix4.clone(camera.transform);
|
||||
|
||||
+49
-81
@@ -14,12 +14,8 @@
|
||||
* lifts the eye if it sits below a usable clearance. Heading and pitch are
|
||||
* held, so the correction reads as the shot settling.
|
||||
*/
|
||||
import { onFollowChanged } from './data/followed.js';
|
||||
import * as Cesium from 'cesium';
|
||||
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. */
|
||||
export const MIN_EYE_CLEARANCE_M = 120;
|
||||
@@ -28,11 +24,6 @@ const GUARD_ATTEMPTS = 8;
|
||||
const GUARD_INTERVAL_MS = 600;
|
||||
/** Ignore sub-metre noise rather than nudging the camera forever. */
|
||||
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();
|
||||
|
||||
@@ -46,60 +37,22 @@ const probeScratch = new Cesium.Cartographic();
|
||||
function sampleSurfaceM(scene, cells) {
|
||||
let heightM = Number.NaN;
|
||||
let sampled = 0;
|
||||
if (!scene) return { heightM, sampled };
|
||||
const heights = heightsFor(scene);
|
||||
if (typeof scene?.sampleHeight !== 'function') return { heightM, sampled };
|
||||
for (const cell of cells) {
|
||||
// A miss is a cell whose tiles are not ready.
|
||||
const height = heights.now(
|
||||
Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch),
|
||||
);
|
||||
if (height === null) continue;
|
||||
sampled += 1;
|
||||
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
|
||||
try {
|
||||
const height = scene.sampleHeight(
|
||||
Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch),
|
||||
);
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 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.
|
||||
* Pure so the policy is testable without a scene.
|
||||
@@ -123,10 +76,8 @@ export function groundClearanceDeficitM(
|
||||
* 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
|
||||
* controls (pointer or wheel on the canvas), `isStale()` reporting that a
|
||||
* 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.
|
||||
* controls (pointer or wheel on the canvas), or `isStale()` reporting that a
|
||||
* newer arrival owns the camera.
|
||||
* @param {Cesium.Viewer} viewer Live viewer.
|
||||
* @param {{lat: number, lon: number}} at Destination being framed.
|
||||
* @param {{clearanceM?: number, attempts?: number, intervalMs?: number,
|
||||
@@ -144,10 +95,6 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
|
||||
if (!viewer?.scene || !Number.isFinite(at?.lat) || !Number.isFinite(at?.lon))
|
||||
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 done = false;
|
||||
const canvas = viewer.scene.canvas;
|
||||
@@ -165,7 +112,32 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
|
||||
release();
|
||||
};
|
||||
release = ownCameraArrival(viewer, stop);
|
||||
removers.push(onFollowChanged(viewer, stop));
|
||||
// Direct camera API callers (including duration-zero flights) must yield
|
||||
// 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);
|
||||
// A manual gesture ends the guard, as it interrupts camera motion elsewhere.
|
||||
for (const type of ['pointerdown', 'wheel'])
|
||||
@@ -175,10 +147,8 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
|
||||
const attempt = () => {
|
||||
if (done) return;
|
||||
if (isStale() || viewer.isDestroyed?.()) return stop();
|
||||
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 camera = viewer.camera;
|
||||
const carto = camera.positionCartographic;
|
||||
// The eye must clear both the framed target and the ground beneath it.
|
||||
const below = {
|
||||
@@ -197,22 +167,20 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
|
||||
);
|
||||
if (deficit > 0) {
|
||||
stop();
|
||||
cameraOf(viewer).flyTo(
|
||||
Cesium.Cartesian3.fromRadians(
|
||||
camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
carto.height + deficit,
|
||||
),
|
||||
{
|
||||
orientation: {
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
},
|
||||
durationS: 0.9,
|
||||
easing: 'cubicInOut',
|
||||
orientation: {
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
},
|
||||
);
|
||||
duration: 0.9,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
try {
|
||||
onLift?.(deficit);
|
||||
} catch {
|
||||
|
||||
+22
-146
@@ -3,7 +3,6 @@
|
||||
// Arrival framing is computed before the destination's tiles exist, so it
|
||||
// works from a predicted ground height. The guard measures the real surface
|
||||
// after arrival and lifts the eye; these tests pin when it acts.
|
||||
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
@@ -50,12 +49,8 @@ test('the clearance frames a subject without turning into an overflight', () =>
|
||||
|
||||
function fakeViewer({ cameraHeightM, surfaceM }) {
|
||||
const flights = [];
|
||||
const position = Cesium.Cartographic.fromDegrees(
|
||||
-97.7603,
|
||||
30.3141,
|
||||
cameraHeightM,
|
||||
);
|
||||
return bindFakeCesiumCamera({
|
||||
const position = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, cameraHeightM);
|
||||
return {
|
||||
flights,
|
||||
scene: {
|
||||
canvas: null,
|
||||
@@ -64,19 +59,12 @@ function fakeViewer({ cameraHeightM, surfaceM }) {
|
||||
},
|
||||
camera: {
|
||||
positionCartographic: position,
|
||||
positionWC: Cesium.Cartographic.toCartesian(position),
|
||||
directionWC: new Cesium.Cartesian3(0, 0, -1),
|
||||
upWC: new Cesium.Cartesian3(0, 1, 0),
|
||||
heading: 0.5,
|
||||
pitch: -0.55,
|
||||
roll: 0,
|
||||
// As Cesium does: a flight is under way until it completes.
|
||||
flyTo(options) {
|
||||
flights.push(options);
|
||||
this._currentFlight = {};
|
||||
},
|
||||
flyTo: (options) => flights.push(options),
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
test('the guard lifts a buried arrival once the surface answers', async () => {
|
||||
@@ -116,11 +104,7 @@ test('the guard waits for streaming tiles and yields to a newer arrival', async
|
||||
|
||||
test('the guard leaves a clear view untouched', async () => {
|
||||
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));
|
||||
assert.equal(viewer.flights.length, 0);
|
||||
});
|
||||
@@ -138,76 +122,33 @@ function handoffFixture(t) {
|
||||
viewer.trackedEntityChanged = new Cesium.Event();
|
||||
viewer.camera.cancelFlight = () => {};
|
||||
viewer.camera.lookAtTransform = () => {};
|
||||
viewer.camera.flyToBoundingSphere = function () {
|
||||
this._currentFlight = {};
|
||||
};
|
||||
viewer.camera.setView = function () {
|
||||
this.positionWC = Cesium.Cartesian3.add(
|
||||
this.positionWC,
|
||||
new Cesium.Cartesian3(0, 0, 50),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
};
|
||||
viewer.camera.flyToBoundingSphere = () => {};
|
||||
viewer.camera.setView = () => {};
|
||||
const originalFly = viewer.camera.flyTo;
|
||||
const navigation = new NavigationController({
|
||||
viewer,
|
||||
tracking: Object.fromEntries(
|
||||
[
|
||||
'flightsLayer',
|
||||
'militaryFlightsLayer',
|
||||
'satellitesLayer',
|
||||
'aisLiveVesselsLayer',
|
||||
'militaryAwarenessLayer',
|
||||
'rocketLaunchesLayer',
|
||||
].map((key) => [key, {}]),
|
||||
),
|
||||
cancelOrientation() {},
|
||||
clearLocation() {},
|
||||
cancelShareSelection() {},
|
||||
getDataManager() {},
|
||||
interruptCameraMotion() {},
|
||||
stopOrbit() {},
|
||||
isCockpitActive: () => false,
|
||||
showToast() {},
|
||||
tracking: Object.fromEntries(['flightsLayer', 'militaryFlightsLayer',
|
||||
'satellitesLayer', 'aisLiveVesselsLayer', 'militaryAwarenessLayer',
|
||||
'rocketLaunchesLayer'].map((key) => [key, {}])),
|
||||
cancelOrientation() {}, clearLocation() {}, cancelShareSelection() {},
|
||||
getDataManager() {}, interruptCameraMotion() {}, stopOrbit() {},
|
||||
isCockpitActive: () => false, showToast() {},
|
||||
});
|
||||
guardCameraAboveGround(viewer, { lat: 30.3, lon: -97.7 });
|
||||
return { viewer, navigation, originalFly };
|
||||
}
|
||||
|
||||
for (const handoff of [
|
||||
'Director',
|
||||
'tracking',
|
||||
'follow',
|
||||
'keyboard',
|
||||
'UI',
|
||||
'flyTo',
|
||||
'flyToBoundingSphere',
|
||||
'setView',
|
||||
'layer-disable',
|
||||
'layer-destroy',
|
||||
'app-stop',
|
||||
]) {
|
||||
for (const handoff of ['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) => {
|
||||
const { viewer, navigation, originalFly } = handoffFixture(t);
|
||||
const methods = [
|
||||
'flyTo',
|
||||
'flyToBoundingSphere',
|
||||
'setView',
|
||||
'lookAt',
|
||||
'cancelFlight',
|
||||
].map((name) => viewer.camera[name]);
|
||||
if (handoff === 'Director') {
|
||||
SceneDirector.prototype._claimCameraOwnership.call({
|
||||
viewer,
|
||||
styleManager: {
|
||||
runImmediateNavigation: (noun, fn) =>
|
||||
navigation._runExplicitNavigation(noun, fn),
|
||||
},
|
||||
viewer, styleManager: { runImmediateNavigation: (noun, fn) =>
|
||||
navigation._runExplicitNavigation(noun, fn) },
|
||||
});
|
||||
} else if (handoff === 'tracking') {
|
||||
// As Cesium's setter does: the value changes, then the event fires.
|
||||
viewer.trackedEntity = { id: 'contact' };
|
||||
viewer.trackedEntityChanged.raiseEvent(viewer.trackedEntity);
|
||||
viewer.trackedEntityChanged.raiseEvent({ id: 'contact' });
|
||||
} else if (handoff === 'follow' || handoff === 'UI') {
|
||||
navigation._stampNavigation({ cancelPendingSelection: false });
|
||||
} else if (handoff === 'keyboard') {
|
||||
@@ -215,81 +156,16 @@ for (const handoff of [
|
||||
} else if (handoff === 'app-stop') navigation.stop();
|
||||
else if (handoff.startsWith('layer-')) {
|
||||
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);
|
||||
if (handoff === 'layer-disable')
|
||||
await lifecycle.setEnabled('test', false);
|
||||
if (handoff === 'layer-disable') await lifecycle.setEnabled('test', false);
|
||||
else await lifecycle.destroyLayer('test');
|
||||
} else viewer.camera[handoff]({});
|
||||
// A direct flyTo is itself one recorded flight; the guard adds none.
|
||||
const priorFlights = viewer.flights.length;
|
||||
t.mock.timers.tick(10000);
|
||||
assert.equal(
|
||||
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.flights.length, priorFlights, 'the old probe cannot fly');
|
||||
assert.equal(viewer.camera.flyTo, originalFly, 'direct-call hook is removed');
|
||||
assert.equal(viewer.trackedEntityChanged.numberOfListeners, 0);
|
||||
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, [
|
||||
'if (!info || !entity?.position) return false;',
|
||||
'this.onCameraTakeover?.();',
|
||||
'cameraOf(this.viewer).cancelFlight();',
|
||||
'stopFollowing(this.viewer);',
|
||||
'this.viewer.camera.cancelFlight();',
|
||||
'this.viewer.trackedEntity = undefined;',
|
||||
], 'Cockpit takeover');
|
||||
assert.match(
|
||||
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
|
||||
// stamps:
|
||||
// 1. selectedLayer.trackById(id) -> viewer.trackedEntity
|
||||
// -> onFollowChanged(viewer) -> _stampNavigation()
|
||||
// -> viewer.trackedEntityChanged -> _stampNavigation()
|
||||
// 2. cockpitView.enter() -> onCameraTakeover() -> _stampNavigation()
|
||||
const transaction = body(
|
||||
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.
|
||||
assert.match(
|
||||
ui,
|
||||
/onFollowChanged\(\s*this\.viewer,\s*\(followed\) => \{\s*if \(followed && !this\._disposed\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/,
|
||||
/viewer\.trackedEntityChanged\.addEventListener\(\s*\(entity\) => \{\s*if \(entity && !this\._disposed\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/,
|
||||
'tracker handoff must stamp',
|
||||
);
|
||||
// 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',
|
||||
'satellitesLayer.stopTracking?.({ origin: trackingOrigin })',
|
||||
'rocketLaunchesLayer.releaseCameraOwnership?.()',
|
||||
'stopFollowing(this.viewer);',
|
||||
'this.viewer.trackedEntity = undefined;',
|
||||
"interruptCameraMotion('explicit-navigation')",
|
||||
'camera?.cancelFlight();',
|
||||
'camera?.release();',
|
||||
'this.viewer.camera.cancelFlight();',
|
||||
'this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);',
|
||||
], 'follow release');
|
||||
assert.match(release, /flightsLayer\.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(
|
||||
vessels,
|
||||
/inputFor\(viewer\)\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/,
|
||||
/handler\.setInputAction\(\s*\(click\) => \{([\s\S]*?)\n\s*\},\s*Cesium\.ScreenSpaceEventType\.LEFT_CLICK,?\s*\);/,
|
||||
'vessel click',
|
||||
);
|
||||
ordered(vesselClick, [
|
||||
@@ -405,7 +405,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
|
||||
assert.match(vesselFocus, /requestWorldFocus\(\{/);
|
||||
const fireClick = body(
|
||||
firms,
|
||||
/input\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/,
|
||||
/_clickHandler\.setInputAction\(\(click\) => \{([\s\S]*?)\n \}, Cesium\.ScreenSpaceEventType\.LEFT_CLICK\);/,
|
||||
'fire click',
|
||||
);
|
||||
ordered(fireClick, [
|
||||
|
||||
+46
-42
@@ -18,7 +18,6 @@
|
||||
* globe motion, opposite to the camera's travel around Earth.
|
||||
*/
|
||||
|
||||
import { followedOf, isFollowing, onFollowChanged } from './data/followed.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { GLOBE_VIEW } from './locations.js';
|
||||
import {
|
||||
@@ -30,9 +29,6 @@ import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
} 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;
|
||||
/** °/s by speed word — orbit; pan uses fractions of view height/s. */
|
||||
@@ -563,18 +559,25 @@ function acquireCorridorFloor(state, takeWhole) {
|
||||
export function probeMeshFloorM(scene, cells) {
|
||||
const requested = Array.isArray(cells) ? cells.length : 0;
|
||||
const empty = { heightM: Number.NaN, sampled: 0, requested };
|
||||
if (!scene || !requested) return empty;
|
||||
const heights = heightsFor(scene);
|
||||
if (!scene || typeof scene.sampleHeight !== 'function' || !requested)
|
||||
return empty;
|
||||
let heightM = Number.NaN;
|
||||
let sampled = 0;
|
||||
for (const cell of cells) {
|
||||
// A miss is a cell whose tiles are not ready.
|
||||
const height = heights.now(
|
||||
Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, _probeCarto),
|
||||
);
|
||||
if (height === null) continue;
|
||||
sampled += 1;
|
||||
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
|
||||
try {
|
||||
const carto = Cesium.Cartographic.fromDegrees(
|
||||
cell.lon,
|
||||
cell.lat,
|
||||
0,
|
||||
_probeCarto,
|
||||
);
|
||||
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 };
|
||||
}
|
||||
@@ -1007,7 +1010,7 @@ export function interruptCameraMotionIfActive(
|
||||
|
||||
function clearLookAt() {
|
||||
try {
|
||||
findCamera(_viewer)?.release();
|
||||
_viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
} catch {
|
||||
/* teardown race */
|
||||
}
|
||||
@@ -1025,11 +1028,13 @@ function clearLookAt() {
|
||||
*/
|
||||
function levelCameraRoll() {
|
||||
try {
|
||||
const camera = findCamera(_viewer);
|
||||
if (!camera) return false;
|
||||
const { heading, pitch, roll } = camera.pose();
|
||||
if (Math.abs(Cesium.Math.negativePiToPi(roll || 0)) < 1e-5) return false;
|
||||
camera.setView({ orientation: { heading, pitch, roll: 0 } });
|
||||
const cam = _viewer?.camera;
|
||||
if (!cam || typeof cam.setView !== 'function') return false;
|
||||
const roll = Cesium.Math.negativePiToPi(cam.roll || 0);
|
||||
if (Math.abs(roll) < 1e-5) return false;
|
||||
cam.setView({
|
||||
orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false; // teardown race
|
||||
@@ -1042,7 +1047,7 @@ export function interruptCameraMotion(reason = 'interrupt') {
|
||||
// While an entity is TRACKED, the follow camera owns the reference frame —
|
||||
// resetting the lookAt transform here flings the camera into space (field
|
||||
// finding). Leave the transform to the tracker; it re-asserts every frame.
|
||||
const tracking = isFollowing(_viewer);
|
||||
const tracking = Boolean(_viewer?.trackedEntity);
|
||||
const wasRoute = wasActive && _active.kind === 'route';
|
||||
if (
|
||||
wasActive &&
|
||||
@@ -1082,9 +1087,6 @@ export function getActiveCameraMotion() {
|
||||
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() {
|
||||
if (!_active || !_viewer) return;
|
||||
// Wall-clock dt: clock.currentTime FREEZES when the app clock isn't
|
||||
@@ -1095,8 +1097,6 @@ function onTick() {
|
||||
Math.max(0.001, (nowMs - (_active.lastMs ?? nowMs)) / 1000),
|
||||
);
|
||||
_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 m = _active;
|
||||
|
||||
@@ -1112,7 +1112,6 @@ function onTick() {
|
||||
}
|
||||
|
||||
try {
|
||||
const camera = cameraOf(_viewer);
|
||||
if (m.kind === 'orbit' && m.pending) {
|
||||
// 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.
|
||||
@@ -1162,7 +1161,7 @@ function onTick() {
|
||||
} else {
|
||||
m.hpr.heading += rate * dt;
|
||||
}
|
||||
camera.lookAt(m.target, m.hpr);
|
||||
cam.lookAt(m.target, m.hpr);
|
||||
} else if (m.kind === 'pan') {
|
||||
const heightM = Math.max(50, cam.positionCartographic.height);
|
||||
const step =
|
||||
@@ -1170,7 +1169,10 @@ function onTick() {
|
||||
? heightM * ONCE.panViewFraction * (scale - (m.lastScale || 0))
|
||||
: heightM * PAN_VIEW_FRACTION_S[m.speed] * dt;
|
||||
m.lastScale = scale;
|
||||
camera.move(PAN_MOVES[m.direction] ?? 'down', step);
|
||||
if (m.direction === 'left') cam.moveLeft(step);
|
||||
else if (m.direction === 'right') cam.moveRight(step);
|
||||
else if (m.direction === 'up') cam.moveUp(step);
|
||||
else cam.moveDown(step);
|
||||
} else if (
|
||||
m.kind === 'tilt' ||
|
||||
m.kind === 'rotate' ||
|
||||
@@ -1191,7 +1193,7 @@ function onTick() {
|
||||
// only describes Cesium's rotation while the frame is identity.
|
||||
if (
|
||||
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
|
||||
isFollowing(_viewer) ||
|
||||
_viewer.trackedEntity ||
|
||||
_isCockpitActive?.() ||
|
||||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
|
||||
) {
|
||||
@@ -1215,7 +1217,7 @@ function onTick() {
|
||||
interruptCameraMotion('globe-surface-limit');
|
||||
return;
|
||||
}
|
||||
camera.rotateAround(m.rotationAxis, angle);
|
||||
cam.rotate(m.rotationAxis, angle);
|
||||
} else if (m.kind === 'tilt') {
|
||||
const up = m.direction === 'up';
|
||||
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
|
||||
@@ -1224,8 +1226,10 @@ function onTick() {
|
||||
interruptCameraMotion('tilt-clamp');
|
||||
return;
|
||||
}
|
||||
camera.look(up ? 'up' : 'down', stepRad);
|
||||
} else camera.look(m.direction === 'left' ? 'left' : 'right', stepRad);
|
||||
if (up) cam.lookUp(stepRad);
|
||||
else cam.lookDown(stepRad);
|
||||
} else if (m.direction === 'left') cam.lookLeft(stepRad);
|
||||
else cam.lookRight(stepRad);
|
||||
} else if (m.kind === 'route') {
|
||||
// The dolly's shaping lives in advanceRouteFlight(); the tick only
|
||||
// applies the pose and honours the same completion path as before.
|
||||
@@ -1234,7 +1238,7 @@ function onTick() {
|
||||
// user left it rather than teleporting to an altitude derived from
|
||||
// nothing. An interrupt during the arm is still instant.
|
||||
if (frame.arming) return;
|
||||
camera.setView({
|
||||
cam.setView({
|
||||
destination: frame.eye,
|
||||
orientation: { direction: frame.direction, up: frame.up },
|
||||
});
|
||||
@@ -1414,8 +1418,8 @@ export function initCameraVerbs(
|
||||
if (_trackingRemover) _trackingRemover();
|
||||
// Cockpit can acquire then clear tracking before the next clock tick.
|
||||
// Yield on acquisition itself without changing legacy camera motions.
|
||||
_trackingRemover = onFollowChanged(viewer, (followed) => {
|
||||
if (followed && _active?.kind === 'rotate_globe')
|
||||
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
|
||||
if (entity && _active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
});
|
||||
for (const rm of _inputRemovers) rm();
|
||||
@@ -1523,14 +1527,15 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
if (
|
||||
(motion === 'rotate_globe' ||
|
||||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
|
||||
isFollowing(_viewer)
|
||||
_viewer?.trackedEntity
|
||||
) {
|
||||
// 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
|
||||
// until tracked-orbit rides the follow camera natively (roadmap).
|
||||
const followed = followedOf(_viewer);
|
||||
const label =
|
||||
followed?.name || followed?.labelModel?.title || 'the tracked target';
|
||||
_viewer.trackedEntity?.name ||
|
||||
_viewer.trackedEntity?.id ||
|
||||
'the tracked target';
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
@@ -1613,10 +1618,9 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
if (motion === 'rotate_globe') {
|
||||
// Convert an inherited ground-target frame without changing the world
|
||||
// pose. Rotation must pivot about Earth, never the previous local target.
|
||||
const camera = cameraOf(_viewer);
|
||||
camera.cancelFlight();
|
||||
camera.release();
|
||||
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.
|
||||
// Copy the basis directly: setView's heading/pitch conversion is singular
|
||||
// at nadir and can change the captured orientation near the poles.
|
||||
@@ -1701,7 +1705,7 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
const preserveCameraFlight =
|
||||
motion === 'orbit' &&
|
||||
_viewer.scene.tweens.length > 0 &&
|
||||
!isFollowing(_viewer);
|
||||
!_viewer.trackedEntity;
|
||||
return typeof runNavigation === 'function'
|
||||
? runNavigation(start, { preserveCameraFlight })
|
||||
: start();
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
/**
|
||||
* Activates the nearest CCTV record on layer enable and flies only when no
|
||||
* followed target or cockpit camera owns the view.
|
||||
* aircraft tracker or cockpit camera owns the view.
|
||||
* @param {Object} options Layer-enable focus inputs.
|
||||
* @param {*} options.followed What the camera follows, if anything
|
||||
* (`followedOf`).
|
||||
* @param {*} options.trackedEntity Current Cesium tracked entity, if any.
|
||||
* @param {boolean} options.cockpitActive Whether cockpit mode owns the camera.
|
||||
* @param {() => (string|null)} options.activate Nearest-camera activation.
|
||||
* @param {(cameraId: string) => *} options.fly Optional camera flight.
|
||||
* @returns {*} Activated camera ID for a held view, or the flight result.
|
||||
*/
|
||||
export function runCctvLayerEnableFocus({
|
||||
followed = null,
|
||||
trackedEntity = null,
|
||||
cockpitActive = false,
|
||||
activate,
|
||||
fly,
|
||||
} = {}) {
|
||||
const cameraId = activate?.();
|
||||
if (!cameraId) return false;
|
||||
if (followed || cockpitActive) return cameraId;
|
||||
if (trackedEntity || cockpitActive) return cameraId;
|
||||
return fly?.(cameraId);
|
||||
}
|
||||
|
||||
@@ -26,11 +25,11 @@ export function runCctvLayerEnableFocus({
|
||||
* chain. Either observation suppresses the optional CCTV focus flight.
|
||||
* @param {Object} [before={}] Ownership immediately before the await.
|
||||
* @param {Object} [after={}] Ownership immediately after the await.
|
||||
* @returns {{ followed: *, cockpitActive: boolean }} Conservative ownership.
|
||||
* @returns {{ trackedEntity: *, cockpitActive: boolean }} Conservative ownership.
|
||||
*/
|
||||
function mergeCctvEnableOwnership(before = {}, after = {}) {
|
||||
return {
|
||||
followed: before.followed || after.followed || null,
|
||||
trackedEntity: before.trackedEntity || after.trackedEntity || null,
|
||||
cockpitActive: !!(before.cockpitActive || after.cockpitActive),
|
||||
};
|
||||
}
|
||||
@@ -61,7 +60,7 @@ export async function runCctvLayerEnableTransition({
|
||||
const ownershipBefore = readOwnership?.() || {};
|
||||
if (target === true) {
|
||||
debug?.('[UI:CCTV] ownership before setEnabled await', {
|
||||
trackedId: ownershipBefore.followed?.key ?? null,
|
||||
trackedId: ownershipBefore.trackedEntity?.id ?? null,
|
||||
cockpitActive: !!ownershipBefore.cockpitActive,
|
||||
});
|
||||
}
|
||||
@@ -69,7 +68,7 @@ export async function runCctvLayerEnableTransition({
|
||||
const ownershipAfter = readOwnership?.() || {};
|
||||
if (target === true) {
|
||||
debug?.('[UI:CCTV] ownership after setEnabled await', {
|
||||
trackedId: ownershipAfter.followed?.key ?? null,
|
||||
trackedId: ownershipAfter.trackedEntity?.id ?? null,
|
||||
cockpitActive: !!ownershipAfter.cockpitActive,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
|
||||
test('CCTV layer enable activates nearest without stealing a tracked or cockpit view', () => {
|
||||
for (const ownership of [
|
||||
{ followed: { key: 'tracked-plane' }, cockpitActive: false },
|
||||
{ followed: null, cockpitActive: true },
|
||||
{ trackedEntity: { id: 'tracked-plane' }, cockpitActive: false },
|
||||
{ trackedEntity: null, cockpitActive: true },
|
||||
]) {
|
||||
const calls = [];
|
||||
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 () => {
|
||||
let followed = { key: 'tracked-plane' };
|
||||
let trackedEntity = { id: 'tracked-plane' };
|
||||
let flyCalls = 0;
|
||||
const diagnostics = [];
|
||||
const result = await runCctvLayerEnableTransition({
|
||||
target: true,
|
||||
readOwnership: () => ({ followed, cockpitActive: false }),
|
||||
readOwnership: () => ({ trackedEntity, cockpitActive: false }),
|
||||
setEnabled: async () => {
|
||||
await Promise.resolve();
|
||||
followed = null;
|
||||
trackedEntity = null;
|
||||
},
|
||||
shouldFocus: () => true,
|
||||
activate: () => 'cam-near',
|
||||
@@ -83,7 +83,7 @@ test('CCTV disable transition does not emit enable-ownership diagnostics', async
|
||||
|
||||
const result = await runCctvLayerEnableTransition({
|
||||
target: false,
|
||||
readOwnership: () => ({ followed: { key: 'tracked-plane' }, cockpitActive: false }),
|
||||
readOwnership: () => ({ trackedEntity: { id: 'tracked-plane' }, cockpitActive: false }),
|
||||
setEnabled: async (target) => transitions.push(target),
|
||||
shouldFocus: () => true,
|
||||
activate: () => 'cam-near',
|
||||
|
||||
+39
-19
@@ -1,6 +1,9 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { governorRequestRender } from './renderGovernor.js';
|
||||
import { cameraOf } from './render/cesium/camera.js';
|
||||
import { worldClock, worldTimeFor } from './time/worldClock.js';
|
||||
|
||||
// The ring shows the sun and moon at the time the sky is drawn.
|
||||
const skyTime = worldTimeFor('sky');
|
||||
|
||||
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
|
||||
export const KEYHOLE_OUTER_RADIUS = 1.05;
|
||||
@@ -29,6 +32,9 @@ export const KEYHOLE_LABEL_FEATHER_MAX_RATIO = 0.4;
|
||||
*/
|
||||
export const KEYHOLE_OUTSIDE_OPACITY_DEFAULT = 0.01;
|
||||
|
||||
// How far the world time moves before the sun and moon move with it: a
|
||||
// minute moves the sun a quarter of a degree.
|
||||
const EPHEMERIS_WORLD_STEP_MS = 60_000;
|
||||
const RING_INSET_PX = 11;
|
||||
const MARKER_INSET_PX = 36;
|
||||
const COLLIDING_MARKER_EXTRA_INSET_PX = 32;
|
||||
@@ -446,6 +452,20 @@ export class CelestialRing {
|
||||
if (typeof document !== 'undefined' && document.hidden) return;
|
||||
governorRequestRender('celestial-ephemeris');
|
||||
}, 60_000);
|
||||
// A jump in the world time moves the sun and moon at once.
|
||||
this._skyDate = new Date();
|
||||
this._skyTime = new Cesium.JulianDate();
|
||||
// Played through, the sky moves on each minute of world time it passes.
|
||||
this._ephemerisWorldMs = null;
|
||||
this._unsubscribeWorldTime = worldClock.subscribe(({ timeMs }) => {
|
||||
if (
|
||||
this._ephemerisWorldMs !== null &&
|
||||
Math.abs(timeMs - this._ephemerisWorldMs) < EPHEMERIS_WORLD_STEP_MS
|
||||
)
|
||||
return;
|
||||
this._ephemerisWorldMs = timeMs;
|
||||
this._ephemerisDirty = true;
|
||||
});
|
||||
this.setEnabled(this.enabled);
|
||||
}
|
||||
|
||||
@@ -577,33 +597,30 @@ export class CelestialRing {
|
||||
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
|
||||
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
|
||||
|
||||
const camera = cameraOf(this.viewer);
|
||||
this._focusInProgress = true;
|
||||
const finishFocus = () => {
|
||||
this._focusInProgress = false;
|
||||
this.viewer.scene.requestRender?.();
|
||||
};
|
||||
camera.flyTo(
|
||||
Cesium.Cartesian3.fromRadians(
|
||||
this.viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
cartographic.longitude,
|
||||
cartographic.latitude,
|
||||
altitude,
|
||||
),
|
||||
{
|
||||
orientation: {
|
||||
heading: camera.pose().heading,
|
||||
pitch: -Cesium.Math.PI_OVER_TWO,
|
||||
roll: 0,
|
||||
},
|
||||
durationS: duration,
|
||||
easing: 'cubicInOut',
|
||||
onComplete: finishFocus,
|
||||
onCancel: () => {
|
||||
finishFocus();
|
||||
if (!this.isGlobeFullyVisible()) this._autoDisable();
|
||||
},
|
||||
orientation: {
|
||||
heading: this.viewer.camera.heading,
|
||||
pitch: -Cesium.Math.PI_OVER_TWO,
|
||||
roll: 0,
|
||||
},
|
||||
);
|
||||
duration,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
complete: finishFocus,
|
||||
cancel: () => {
|
||||
finishFocus();
|
||||
if (!this.isGlobeFullyVisible()) this._autoDisable();
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -805,7 +822,8 @@ export class CelestialRing {
|
||||
return;
|
||||
}
|
||||
|
||||
const time = Cesium.JulianDate.now();
|
||||
this._skyDate.setTime(skyTime());
|
||||
const time = Cesium.JulianDate.fromDate(this._skyDate, this._skyTime);
|
||||
if (!this._updateEphemeris(time)) return;
|
||||
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
|
||||
const camera = this.viewer.camera;
|
||||
@@ -894,6 +912,8 @@ export class CelestialRing {
|
||||
clearInterval(this._ephemerisTimer);
|
||||
this._ephemerisTimer = null;
|
||||
}
|
||||
this._unsubscribeWorldTime?.();
|
||||
this._unsubscribeWorldTime = null;
|
||||
if (this._removePostRender) this._removePostRender();
|
||||
this._removePostRender = null;
|
||||
if (this._shown()) {
|
||||
|
||||
@@ -227,7 +227,7 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
|
||||
assert.doesNotMatch(exit, /applyTrackedCameraFrame/);
|
||||
assert.match(
|
||||
ui,
|
||||
/restoreTrackingFrame: \(entity\) => \{[\s\S]*?readEntityRecord\(entity\)\?\.trackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/,
|
||||
/restoreTrackingFrame: \(entity\) => \{[\s\S]*?gevTrackedId[\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',
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && followedOf\(this\.viewer\)\?\.entity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
|
||||
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.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 the camera follows', () => {
|
||||
test('the cockpit reads its aircraft from the layer that owns Cesium tracking', () => {
|
||||
const read = readAircraftInfo.toString();
|
||||
assert.ok(read, 'readAircraftInfo is missing');
|
||||
assert.match(read, /resolveTrackedAircraftInfo\(\{/);
|
||||
assert.match(read, /trackedId: followed\?\.key/);
|
||||
// In cockpit mode the controller stops the camera following, so its own
|
||||
// handle is the tracked identity there.
|
||||
assert.match(read, /followedOf\(this\.viewer\) \|\| this\.trackedFollow/);
|
||||
assert.match(read, /gevTrackedId/);
|
||||
// In cockpit mode the controller moves the entity off viewer.trackedEntity,
|
||||
// so its own handle is the tracked identity there.
|
||||
assert.match(read, /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const match = _adoptTrackedEntity.toString();
|
||||
assert.ok(match, 'cockpit tracked-entity handoff block is missing');
|
||||
assert.match(match, /stopFollowing\(this\.viewer\);/);
|
||||
assert.match(match, /this\.viewer\.trackedEntity = undefined;/);
|
||||
assert.match(match, /this\.cockpitAnchorValid = false;/);
|
||||
assert.match(
|
||||
match,
|
||||
|
||||
+1
-1
@@ -306,7 +306,7 @@ export function relativeBearing(bearing, heading) {
|
||||
* Resolve which flight layer owns the cockpit's tracked aircraft.
|
||||
*
|
||||
* Both layers can briefly describe a tracked aircraft during a cross-layer
|
||||
* handoff, so the viewer's normalized tracked identity (the entity record's `trackedId`) is the
|
||||
* handoff, so the viewer's normalized tracked identity (`gevTrackedId`) is the
|
||||
* tie-breaker — the same rule `militaryAwareness.currentTrackedFlightSubject`
|
||||
* uses. Layer precedence remains the fallback when no normalized identity is
|
||||
* 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 military = { icao24: 'ae01ce' };
|
||||
// No stamped identity (or one that matches neither layer) keeps the historic
|
||||
// civilian-first order so tracking paths without an entity record trackedId still work.
|
||||
// civilian-first order so tracking paths without gevTrackedId still work.
|
||||
assert.equal(resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights');
|
||||
assert.equal(
|
||||
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'satellites:25544' }).layerId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* Cockpit aircraft metadata itself is resolved by `resolveTrackedAircraftInfo`
|
||||
* in `cockpitMath.js` — the landed owner of that rule. It keeps layer
|
||||
* precedence as the fallback when the entity record has no normalized `trackedId`,
|
||||
* precedence as the fallback when no normalized `gevTrackedId` is available,
|
||||
* so tracking paths that never stamp one keep working; `readAircraftInfo` is
|
||||
* pinned to it by `cockpitMarkup.test.mjs`. This module owns only the
|
||||
* multi-step ENTRY TRANSACTION (adopt → enter → roll back).
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
enterCockpitWithTracking,
|
||||
restoreAircraftTrackingOwner,
|
||||
} from './cockpitTracking.js';
|
||||
import { readEntityRecord, withEntityRecord } from './data/entityRecords.js';
|
||||
|
||||
/**
|
||||
* Model the two production stamping seams that sit under this transaction:
|
||||
@@ -23,7 +22,7 @@ function stampingWorld({ entryAllowed = true } = {}) {
|
||||
layerId,
|
||||
trackById(id) {
|
||||
if (!trackable) return false;
|
||||
world.trackedEntity = withEntityRecord({}, { trackedId: `${layerId}:${id}` });
|
||||
world.trackedEntity = { gevTrackedId: `${layerId}:${id}` };
|
||||
stamp('trackedEntityChanged'); // the ui.js listener
|
||||
return true;
|
||||
},
|
||||
@@ -81,7 +80,7 @@ test('a refused Cockpit entry still stamps for the tracker it already moved', ()
|
||||
// Adoption stamped, and the rollback re-track stamped again.
|
||||
assert.deepEqual(world.stamps, ['trackedEntityChanged', 'trackedEntityChanged']);
|
||||
assert.equal(world.generation, 2);
|
||||
assert.equal(readEntityRecord(world.trackedEntity)?.trackedId, 'military:ae01ce');
|
||||
assert.equal(world.trackedEntity.gevTrackedId, 'military:ae01ce');
|
||||
});
|
||||
|
||||
test('a Cockpit entry that mutates nothing takes no camera authority', () => {
|
||||
|
||||
@@ -229,13 +229,30 @@ const GUARDED_PROPS = new Set([
|
||||
|
||||
// Custom properties allowed inside a modelled offset. Each is a pinned-tray
|
||||
// stack height, proven non-negative below, so evaluating them at 0 is a
|
||||
// conservative floor rather than a guess.
|
||||
// conservative floor rather than a guess. The world timeline's lift raises
|
||||
// the dock, its trays and the credit together, so it moves no gap either.
|
||||
const VETTED_VARS = new Set([
|
||||
'--dock-pinned-stack-height',
|
||||
'--dock-lower-pinned-height',
|
||||
'--dock-location-pinned-height',
|
||||
'--dock-presets-pinned-height',
|
||||
'--world-timeline-lift',
|
||||
]);
|
||||
// Custom properties CSS sets to a fixed length besides their 0 default. The
|
||||
// clearance checks run at each length CSS gives them, not only at 0.
|
||||
const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']);
|
||||
const LIFTS_PX = [0, 66, 80];
|
||||
let liftPx = 0;
|
||||
function eachLift(check) {
|
||||
for (const lift of LIFTS_PX) {
|
||||
liftPx = lift;
|
||||
try {
|
||||
check(lift);
|
||||
} finally {
|
||||
liftPx = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Rules whose final compound targets a modelled element (pseudo-elements aside). */
|
||||
function ownBoxEntries() {
|
||||
@@ -315,6 +332,7 @@ function toPx(value, viewportHeight, where) {
|
||||
if (term.startsWith('var(')) {
|
||||
const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim();
|
||||
assert.ok(VETTED_VARS.has(name), `unvetted custom property ${name} in ${where}: "${value}"`);
|
||||
if (name === '--world-timeline-lift') total += liftPx;
|
||||
continue; // proven non-negative; 0 is the conservative floor
|
||||
}
|
||||
assert.match(term, /^-?[\d.]+(px|rem|vh)$/, `unmodelled length term "${term}" in ${where}: "${value}"`);
|
||||
@@ -444,7 +462,8 @@ test('custom properties inside modelled offsets are provably non-negative', () =
|
||||
assert.ok(declared.length >= VETTED_VARS.size, 'every vetted custom property needs a CSS default');
|
||||
for (const decl of declared) {
|
||||
assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`);
|
||||
assert.match(decl.value, /^(0|0px)$/, `${decl.prop} default is not a vetted shape: ${decl.value}`);
|
||||
const shape = FIXED_LENGTH_VARS.has(decl.prop) ? /^(0|\d+px)$/ : /^(0|0px)$/;
|
||||
assert.match(decl.value, shape, `${decl.prop} default is not a vetted shape: ${decl.value}`);
|
||||
}
|
||||
const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
|
||||
const start = layout.indexOf('_updateCommandDockTrayStack() {');
|
||||
@@ -517,16 +536,18 @@ test('every open dock tray clears the required credit at every modelled viewport
|
||||
// number: the dock is anchored at 2vh down to 721px and re-anchors to a flat
|
||||
// 8px at 720px, while the credit keeps its 2vh base throughout.
|
||||
const failures = [];
|
||||
for (const scenario of TRAY_SCENARIOS) {
|
||||
for (const width of WIDTHS.filter((w) => w <= 900)) {
|
||||
for (const height of HEIGHTS) {
|
||||
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
|
||||
if (clearance < MIN_CLEARANCE_PX) {
|
||||
failures.push(`${scenario.name} @ ${width}x${height}: ${clearance.toFixed(1)}px`);
|
||||
eachLift((lift) => {
|
||||
for (const scenario of TRAY_SCENARIOS) {
|
||||
for (const width of WIDTHS.filter((w) => w <= 900)) {
|
||||
for (const height of HEIGHTS) {
|
||||
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
|
||||
if (clearance < MIN_CLEARANCE_PX) {
|
||||
failures.push(`${scenario.name} @ ${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
assert.deepEqual(failures, [], `a dock tray re-enters the credit band:\n ${failures.join('\n ')}\n`);
|
||||
});
|
||||
|
||||
@@ -566,21 +587,24 @@ test('the full-width context rail clears the required credit at every modelled v
|
||||
'none',
|
||||
`at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`,
|
||||
);
|
||||
for (const height of HEIGHTS) {
|
||||
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
|
||||
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
|
||||
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height}: ${clearance.toFixed(1)}px`);
|
||||
}
|
||||
eachLift((lift) => {
|
||||
for (const height of HEIGHTS) {
|
||||
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
|
||||
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
|
||||
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
|
||||
}
|
||||
});
|
||||
}
|
||||
assert.deepEqual(failures, [], `context rail re-enters the credit band at ${failures.join(', ')}`);
|
||||
});
|
||||
|
||||
test('the dock anchor changes at 720px — the 2vh cancellation is band-limited', () => {
|
||||
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, '2vh');
|
||||
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, '8px');
|
||||
const lift = 'var(--world-timeline-lift, 0px)';
|
||||
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, `calc(2vh + ${lift})`);
|
||||
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, `calc(8px + ${lift})`);
|
||||
assert.equal(
|
||||
resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value,
|
||||
'calc(2vh + 5rem)',
|
||||
`calc(2vh + 5rem + ${lift})`,
|
||||
'the credit keeps its 2vh base below 720px — that asymmetry is the whole hazard',
|
||||
);
|
||||
});
|
||||
|
||||
+5
-14
@@ -1,5 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { IconSetPrimitive } from './data/iconSetPrimitive.js';
|
||||
|
||||
// Narrow, version-tested engine adapter. Cesium has no public custom shader
|
||||
// option for native point/billboard collections. Do not patch its prototypes or
|
||||
@@ -65,15 +64,9 @@ export function sonarVertexSource(source, kind) {
|
||||
kind === 'point' ? 'positionHighAndSize' : 'positionHighAndScale';
|
||||
const lowAttribute =
|
||||
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 (
|
||||
(kind === 'icon'
|
||||
? !text.includes('iconAnchorClip')
|
||||
: !text.includes(attribute) || !text.includes(lowAttribute)) ||
|
||||
!text.includes(attribute) ||
|
||||
!text.includes(lowAttribute) ||
|
||||
!text.includes('v_color') ||
|
||||
!/void\s+main\s*\(\s*\)/.test(text)
|
||||
)
|
||||
@@ -90,7 +83,7 @@ void main() {
|
||||
gev_sonar_main();
|
||||
// All corners/glyphs share the contact anchor, before native screen offsets.
|
||||
// Leave gl_Position untouched so appearance never changes geometry or picks.
|
||||
vec4 anchor = ${anchorSource};
|
||||
vec4 anchor = czm_modelViewProjectionRelativeToEye * czm_translateRelativeToEye(${attribute}.xyz, ${lowAttribute}.xyz);
|
||||
if (anchor.w <= 0.0) return;
|
||||
vec2 pixel = anchor.xy / anchor.w * u_gevSonarViewport * 0.5;
|
||||
float floorValue = u_gevSonar.z;
|
||||
@@ -115,7 +108,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;
|
||||
// Apply before native fragment pass classification, never after main().
|
||||
v_color.a *= factor;
|
||||
${kind === 'point' ? 'v_outlineColor.a *= factor;' : kind === 'icon' ? '' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'}
|
||||
${kind === 'point' ? 'v_outlineColor.a *= factor;' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'}
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -215,9 +208,7 @@ export function createCyberSonarGpu(scene, readFrame) {
|
||||
? 'point'
|
||||
: native.owner instanceof Cesium.BillboardCollection
|
||||
? 'billboard'
|
||||
: native.owner instanceof IconSetPrimitive
|
||||
? 'icon'
|
||||
: null;
|
||||
: null;
|
||||
if (!kind || !native.shaderProgram || !native.uniformMap) continue;
|
||||
const source = native.shaderProgram;
|
||||
// Label backgrounds are non-SDF billboard batches. The version-tested
|
||||
|
||||
@@ -3,12 +3,6 @@ import { TRACKED_ICON_COLOR } from '../layers/military/policy.js';
|
||||
import { createRendering as civilRendering } from '../layers/flights/rendering.js';
|
||||
import { createRendering as militaryRendering } from '../layers/military/rendering.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';
|
||||
// Source-to-voice provenance, status retention/clearing, contact-time contracts,
|
||||
// and civil/military billboard, Cockpit and model tint regressions.
|
||||
@@ -468,16 +462,18 @@ test('tint priorities cover normal, priority, emergency, tracking and Cockpit do
|
||||
}
|
||||
});
|
||||
|
||||
test('GERMAN: an existing untracked military icon repaints each poll without losing its fade', () => {
|
||||
test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => {
|
||||
const { flightState, rendering } = renderHarness(militaryRendering);
|
||||
const bb = {
|
||||
color: rendering._fleetBillboardColor('abc123').withAlpha(0.65),
|
||||
};
|
||||
Object.assign(flightState, {
|
||||
_icons: createIconSet(),
|
||||
_billboards: new Map([['abc123', bb]]),
|
||||
_models: new Map(),
|
||||
_positionHistory: new Map(),
|
||||
_displayCourse: new Map(),
|
||||
_cullPositions: new Map(),
|
||||
_groundSnap: { forget() {} },
|
||||
_cockpitContactMode: false,
|
||||
_cockpitNearContacts: new Set(),
|
||||
});
|
||||
const records = new MilitaryFlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
@@ -485,42 +481,18 @@ test('GERMAN: an existing untracked military icon repaints each poll without los
|
||||
floorAltitudeM: (value) => value,
|
||||
});
|
||||
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({
|
||||
flightState,
|
||||
records,
|
||||
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
|
||||
meshFloor: { sampleMeshFloorCells() {} },
|
||||
militaryRegistry: { registerMilitaryIcaos() {} },
|
||||
rendering: parts.rendering,
|
||||
icons,
|
||||
rendering: {
|
||||
...rendering,
|
||||
_syncModelToClass() {},
|
||||
_applyFleetBillboardPresentation() {},
|
||||
},
|
||||
motion: {},
|
||||
tracking: {},
|
||||
queries: {},
|
||||
});
|
||||
@@ -546,7 +518,7 @@ test('GERMAN: an existing untracked military icon repaints each poll without los
|
||||
{ observedAtMs: NOW_MS },
|
||||
);
|
||||
apply(snapshot, {});
|
||||
assert.equal(flightState._icons.color(row).green < 0.5, red);
|
||||
assert.ok(Math.abs(flightState._icons.focus(row) - 0.65) < 1e-6);
|
||||
assert.equal(bb.color.green < 0.5, red);
|
||||
assert.equal(bb.color.alpha, 0.65);
|
||||
}
|
||||
});
|
||||
@@ -1,451 +0,0 @@
|
||||
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,8 +252,6 @@ const _b64 = (s) =>
|
||||
* 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. */
|
||||
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
|
||||
* (close-zoom fleet flybys hand off to 3D models). Keep the 192 px texture so
|
||||
* it stays crisp at its biggest on-screen sizes. */
|
||||
|
||||
@@ -1,888 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
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;
|
||||
}
|
||||
Loaded 100 of 601 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user