Compare commits

..
Author SHA1 Message Date
Sameh Khamis bc61a39738 Merge branch 'sameh/world-clock' into sameh/world-timeline-launches 2026-11-02 17:01:07 -08:00
Sameh Khamis 9ab54675ba Merge remote-tracking branch 'origin/main' into sameh/world-clock
# Conflicts:
#	scripts/package-boundaries.json
2026-11-02 17:01:02 -08:00
Sameh Khamis 3cecc8efbe Offer a minute a second as a play speed
Between real time and an hour a second there was nothing to watch a few
minutes of weather or a launch play out at. The speed menu now offers a
minute a second as its second choice.
2026-11-02 16:47:35 -08:00
Sameh Khamis 8eab6a76a8 Drag the timeline along, and play through it smoothly
The zoomed track now drags: the handle still drags the time, while the
track around it drags the view along (or the time, with all of it shown),
a tap goes to the time under it, two fingers pinch it, and a sideways swipe
or a shift-wheel drags it too, the same with a mouse, a trackpad or a
finger. A view dragged off the handle stays put until the time moves.

Play moved in steps: the world clock told of its time four times a second,
so the handle and the globe jumped, and play stopped at every weather frame
while it drew, loaded ahead or not. The clock now tells of its time each
animation frame, and play no longer waits on the frame it is at: that
frame loads as play goes on, and play holds only at the next frame while
the one before it still loads. Weather loads its next frame ahead while the
world plays too, tells its listeners of frames as they change rather than
every frame, and the sun and moon move on each minute of world time.
2026-11-02 16:13:27 -08:00
Sameh Khamis 4fc9722356 Show a spinner by the time while play waits on data
Play holds while a layer loads the frame for its time, and the bar's only
sign of it was a faint pulse, so a stopped handle read as a stall. The
bubble now shows a small spinner beside the time while play is held. It
comes on once a hold lasts long enough to notice and stays through a run of
slow frames, so neither a quick frame nor the gaps between frames flash it.
2026-11-02 15:11:47 -08:00
Sameh Khamis 287c881c17 Zoom the timeline track around the handle
With layers of very different reach on together, a month of launches and
a few hours of lightning, the short-range ticks were crushed at the end of
the one shared track. Scroll or pinch on the track now zooms it around the
handle, plus and minus zoom from the keyboard, and zero or a double-click
shows it all again. Everything still plays together; zooming only changes
what the track shows, and the view follows the handle as it plays, steps or
is left behind by now. A scrub's live edge goes by the stretch shown, so a
zoomed track can land minutes from now, and a double-click or pinch puts
back the time its first press moved.
2026-11-02 13:53:08 -08:00
Sameh Khamis 497016b084 Add the changelog entry for launches, speed and buffering on the timeline 2026-11-02 13:17:28 -08:00
Sameh Khamis 724109ee17 Merge branch 'sameh/world-clock' into sameh/world-timeline-launches 2026-11-02 13:17:08 -08:00
Sameh Khamis 09d833ed96 Add the changelog entry for the world clock and timeline 2026-11-02 13:17:06 -08:00
Sameh Khamis 0d96fe6acd Merge branch 'sameh/world-clock' into sameh/world-timeline-launches 2026-11-02 13:14:32 -08:00
Sameh Khamis 32e08f5043 Merge remote-tracking branch 'origin/main' into sameh/world-clock
# Conflicts:
#	index.html
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
2026-11-02 13:14:32 -08:00
Sameh Khamis 056b3b58c6 Wait for each frame while the world timeline plays, and quiet the bar
Play no longer runs ahead of the data. The world clock gains a wait, which
holds continuous play where it is while a layer loads what the time needs,
and a gate, which keeps play from passing a time a layer has yet to load.
Weather gates play at its next frame and waits there until the frame shows,
so every frame is seen however fast play runs; it waits no more than eight
seconds on a frame that never comes, and takes a wait only when its frames
change. Play pressed while a frame loads waits for it too. A scrub still
drops what loads and moves on.

The bar loses its long date and the dates at its ends: the time rides in a
bubble over the handle, which pulses while play waits, and the bar runs
left to right in every language. Play starts at real time.
2026-11-02 08:03:46 -08:00
Sameh Khamis d04e304502 Choose how fast the world timeline plays
A speed menu beside play offers real time, an hour, a day or a week a
second, a day a second to start with. A speed chosen while playing applies
at once; chosen while weather's own replay steps its frames, it takes
playback over from where it is. Play starts again from the top only at live
or at the very end, so a pause just before the end plays on. On phones the
menu sits beside the track, a full-size target.
2026-11-02 07:41:50 -08:00
Sameh Khamis e7973e81cf Stop zooming out while the globe is still in view
Cesium sets no limit, so zooming out left the globe a speck among the stars.
On the globe a hand now stops zooming out where the globe spans 30% of the
screen's height, which follows the screen's shape as the app's own
whole-globe view does. A camera already past it, after a flight or a turned
screen, stops there rather than jumping in at the next zoom. Flat maps keep
zooming out unlimited.
2026-11-02 07:27:53 -08:00
Sameh Khamis 0aa54dfa97 Bring launches up as the world time passes them, and play the whole track
Away from live, a launch shows from its lift-off on: its marker, label,
paths and orbit stay hidden while the world time is before it, so scrubbing
or playing brings the launches up in the order they flew. The selected
mission stays whenever it flies, and live shows every launch as before.

Play on the timeline runs the whole track in about a minute, from its start
when at live, and runs the clock itself past weather's own replay. It goes
live when it reaches now, or holds at an end that lies ahead of now. Going
live puts the rate back to 1.
2026-11-02 07:21:01 -08:00
Sameh Khamis 48093108b1 Make the world timeline a scrubber under the dock
The timeline shows whenever a layer with time to show is on, live or not.
Its track runs over the time those layers cover, reaching to now, with a
tick for each launch or frame and a handle to drag; arrows step between the
ticks, and a scrub to now goes live. The bar sits along the bottom of the
screen, and the dock and everything anchored above it rise by its height;
clean view, recording and the cockpit put it away with the dock.
2026-11-01 23:37:12 -08:00
Sameh Khamis 9faec3028e Show the world time on the HUD clock
The HUD's UTC and collection readouts follow the world clock with the sky,
so away from live they read the time the globe is drawn at.
2026-11-01 23:17:53 -08:00
Sameh Khamis 3534398771 Move satellites, launches and the sky with the world time
Layers whose data has a time of its own follow the page's world clock from
the start; live feeds such as flights, military, vessels and transit stay
live. Launches register the span of the launches loaded, so turning them on
brings up the timeline. Each timed layer registers its coverage once and
reports none while off, telling the clock after each step has settled. The
launch feed keeps its own window, and an orbit ring is drawn again once the
world time has moved far from when it was drawn.
2026-11-01 22:53:51 -08:00
Sameh Khamis 690a6637e2 Show the timeline only while a layer with time to show is on
The bar now needs more than the world clock being away from live: a layer
that moves with the clock must be on with time to show. In Core that is
weather radar, satellite or lightning with frames; a host adds layers by
following them. Turning the last one off hides the bar; turning it back
on brings it back.

The world clock reports this as `supported` in its state. Weather products
register their frames as coverage one by one, suspended or not, so the way
back to live stays on screen with reduced motion. Weather refreshes and
satellite catalog changes tell the clock their coverage may have changed.
A coverage that throws counts as none, and the clock reads its state once
per notification round.
2026-11-01 22:36:45 -08:00
Sameh Khamis 434fc9602d Show a basic timeline while the world time is away from live
A bar above the command dock, like a video player's: play and pause on
the left, the world time's date and time in UTC beside it, and Live to
return. It stays hidden while live, so Core looks as before until weather
history is scrubbed or replayed, a host moves the clock, or a link made
away from live is opened. Adds the pause and play_arrow glyphs and the
strings in every catalog.
2026-11-01 21:55:33 -08:00
Sameh Khamis 32ec6ed436 Share links carry the world time when made away from live
Field 28, worldTime, holds the world time of a link made away from live:
zigzag seconds since 2026-01-01, like field 8, but it may lie ahead of the
link's creation (a forecast moment). Opening such a link pauses the world
clock at that time; a link without it leaves the clock alone, so a page
opens live and Back or Forward does not stop a replay. Times no JS Date
can hold are dropped.
2026-11-01 21:50:09 -08:00
Sameh Khamis 0c4da3cdb2 Keep one world clock that every timed layer reads
Core told time eight ways: Date.now() in most layers, JulianDate.now() for
flights and military, Cesium's clock frozen at page load for the sky, the
weather clock for observed imagery. They now read one clock,
src/time/worldClock.js, exported as ./time.

Live is a mode, not a timestamp: while live the clock is the wall clock,
so every layer draws as before. Away from live only the layers that follow
the clock move with it. The weather products follow it, as they followed
the weather transport; a host opts others in with follow(id).

- The weather clock takes its time from the world clock and drives its
  replay, stepping its frames as before.
- Satellites, launches, flights and military (still 30 s and 15 s behind),
  transit, GNSS, Recent Imagery, street level, the HUD and the celestial
  ring read worldTimeFor(id). Cadences stay on the wall clock.
- Cesium's sky (stars, sun, moon, atmosphere, lighting) draws at the world
  time each frame instead of the moment the page loaded.
- Weather frames and satellites declare coverage() for a host's timeline.
2026-11-01 21:47:32 -08:00
601 changed files with 12734 additions and 45856 deletions

No files matched your search

-2
View File
@@ -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
-3
View File
@@ -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>
+8 -11
View File
@@ -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
View File
@@ -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.
-3
View File
@@ -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).
-3
View File
@@ -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).
+5
View File
@@ -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).
-114
View File
@@ -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>
-230
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[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 };
-94
View File
@@ -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
+1 -2
View File
@@ -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
View File
@@ -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`
-5
View File
@@ -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
-171
View File
@@ -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.
+5
View File
@@ -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
View File
@@ -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>
-15
View File
@@ -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
View File
@@ -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"
}
}
+38 -26
View File
@@ -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
View File
@@ -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
View File
@@ -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(
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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;
+2 -2
View File
@@ -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'),
+3 -6
View File
@@ -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
View File
@@ -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;
}
+3 -3
View File
@@ -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) {
+1 -3
View File
@@ -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();
+2 -5
View File
@@ -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');
+2 -3
View File
@@ -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
View File
@@ -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;
+10 -18
View File
@@ -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(
+4 -7
View File
@@ -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
);
}),
);
-28
View File
@@ -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)}`);
}
+23 -40
View File
@@ -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));
-44
View File
@@ -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));
}
});
+1 -1
View File
@@ -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,
+13 -69
View File
@@ -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 };
+9 -58
View File
@@ -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 {
+6 -10
View File
@@ -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));
}
});
});
},
};
}
-2
View File
@@ -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(),
],
+7 -5
View File
@@ -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
+31 -39
View File
@@ -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 */
}
+4 -6
View File
@@ -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
View File
@@ -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. */
+16 -25
View File
@@ -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) {
+2 -3
View File
@@ -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
View File
@@ -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) {
+18 -16
View File
@@ -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,
+7 -4
View File
@@ -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');
}
+1 -1
View File
@@ -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 = {}) {
+2 -3
View File
@@ -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({
+2 -2
View File
@@ -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
View File
@@ -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),
});
});
}
+2 -3
View File
@@ -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: () =>
+4
View File
@@ -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)),
});
}
+5
View File
@@ -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,
});
}
+5 -1
View File
@@ -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,
});
}
+5
View File
@@ -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,
});
}
+4
View File
@@ -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()),
});
}
+2 -1
View File
@@ -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 },
});
}
+3
View File
@@ -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,
});
+3 -4
View File
@@ -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
View File
@@ -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 };
}
+1 -1
View File
@@ -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 },
+7 -50
View File
@@ -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
View File
@@ -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(() => {
+144
View File
@@ -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.
+73
View File
@@ -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
View File
@@ -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();
};
}
+5 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
+9 -9
View File
@@ -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
View File
@@ -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();
+8 -9
View File
@@ -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,
});
}
+6 -6
View File
@@ -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
View File
@@ -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()) {
+8 -8
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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 -1
View File
@@ -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).
+2 -3
View File
@@ -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', () => {
+41 -17
View File
@@ -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
View File
@@ -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
+14 -42
View File
@@ -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);
}
});
-451
View File
@@ -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 };
}
-2
View File
@@ -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. */
-888
View File
@@ -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,
};
}
-138
View File
@@ -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