mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
Compare commits
121
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
769d1944df | ||
|
|
f2ee3c984f | ||
|
|
19de386bcc | ||
|
|
5d23a874b1 | ||
|
|
b616188cbf | ||
|
|
b304fd4a34 | ||
|
|
5673831619 | ||
|
|
6b85990fec | ||
|
|
80cd3476bf | ||
|
|
5754e8f046 | ||
|
|
7a8ba29186 | ||
|
|
a2a3aba1d2 | ||
|
|
2fc9ca8516 | ||
|
|
19ed82af07 | ||
|
|
88803f16e8 | ||
|
|
77b485d619 | ||
|
|
4ba0a1b57f | ||
|
|
2a48e1d186 | ||
|
|
aa33bb5ec3 | ||
|
|
aa9a7d41bf | ||
|
|
cf4e8e1d87 | ||
|
|
2dca762e4a | ||
|
|
60ec1f962b | ||
|
|
89a6f8c96f | ||
|
|
1c234ce027 | ||
|
|
07318cc9a4 | ||
|
|
a9eda1d9e7 | ||
|
|
9f8ed74e7b | ||
|
|
05316bb34b | ||
|
|
b8f8a8685d | ||
|
|
2bcd27c1e0 | ||
|
|
2beef9be39 | ||
|
|
1b52298c6c | ||
|
|
0b19f11ad0 | ||
|
|
9d3548c27c | ||
|
|
39777ad173 | ||
|
|
98fd1eca69 | ||
|
|
ddd3962078 | ||
|
|
f76b6ef9ab | ||
|
|
1c2fe8201f | ||
|
|
8b7cdac910 | ||
|
|
70bdcbfd8a | ||
|
|
d9ecd9869a | ||
|
|
e07b0e9256 | ||
|
|
cbefa2567a | ||
|
|
bdb3851bb2 | ||
|
|
dbf306379f | ||
|
|
7d2294580b | ||
|
|
9e2879c0ba | ||
|
|
8d454bf7f1 | ||
|
|
b2fb4a261e | ||
|
|
8cdd184a90 | ||
|
|
f19a6da5b8 | ||
|
|
6194989018 | ||
|
|
525f48c472 | ||
|
|
8eb1029393 | ||
|
|
1516fc1902 | ||
|
|
a4f9b5e925 | ||
|
|
3b9e83d4a5 | ||
|
|
3af42082b1 | ||
|
|
f6eabb4912 | ||
|
|
f93982f55e | ||
|
|
82190d531a | ||
|
|
0c20ed9975 | ||
|
|
6b2aa95cc6 | ||
|
|
0b5b897c11 | ||
|
|
730ba57073 | ||
|
|
e60caab180 | ||
|
|
07d6417446 | ||
|
|
e974ce096a | ||
|
|
1b7498820f | ||
|
|
090eeb9b11 | ||
|
|
d8dce93ff4 | ||
|
|
bdf5899dda | ||
|
|
5ca2266f53 | ||
|
|
98af799cb4 | ||
|
|
0caffd8b70 | ||
|
|
be44f523e0 | ||
|
|
17864965d9 | ||
|
|
d41c0ccaea | ||
|
|
ba2dda5beb | ||
|
|
a94677aed0 | ||
|
|
80292fab72 | ||
|
|
6d3e7c6c67 | ||
|
|
e925f75a4a | ||
|
|
b493418ed1 | ||
|
|
a2968af45e | ||
|
|
635fb55514 | ||
|
|
2a0f78d169 | ||
|
|
6477f9c910 | ||
|
|
76ee07ee6e | ||
|
|
023b6a1991 | ||
|
|
fb88dd6e1b | ||
|
|
56be69d065 | ||
|
|
168d491038 | ||
|
|
116f28a525 | ||
|
|
77ed0a7c31 | ||
|
|
72f1625257 | ||
|
|
5b9c726f33 | ||
|
|
2c8b3593d1 | ||
|
|
f16fda2350 | ||
|
|
e4955f351f | ||
|
|
9fd434438b | ||
|
|
eabff9005d | ||
|
|
ec0985618c | ||
|
|
34b064fc62 | ||
|
|
122b511d1e | ||
|
|
bc0c4b8489 | ||
|
|
08e90e8182 | ||
|
|
133d82621e | ||
|
|
5980a08533 | ||
|
|
9541963aa6 | ||
|
|
6552a89ddd | ||
|
|
5d9eabc919 | ||
|
|
5f3d1eaa2f | ||
|
|
8602999056 | ||
|
|
9d3d5164fe | ||
|
|
34507f606a | ||
|
|
32b0304c2c | ||
|
|
6bb2856403 | ||
|
|
657fd589d7 |
No files matched your search
+5
-2
@@ -115,7 +115,8 @@ GEMINI_LIVE_MODEL=gemini-3.8-live
|
||||
# Client Token). Client-exposed by design, like GOOGLE_MAPS_API_KEY.
|
||||
MAPILLARY_CLIENT_TOKEN=
|
||||
|
||||
# Optional: OpenSky auth mode (oauth | basic | auto | anon)
|
||||
# Optional: OpenSky auth mode (oauth | auto | anon). OpenSky retired
|
||||
# username/password (basic) sign-in on 2026-03-18; "basic" now loads anonymously.
|
||||
# Default: oauth — requires client credentials below
|
||||
# Set to "anon" to skip aircraft auth (rate-limited but works)
|
||||
OPENSKY_AUTH_MODE=oauth
|
||||
@@ -128,7 +129,9 @@ OPENSKY_CLIENT_SECRET=
|
||||
# Optional paid Patreon tokens raise request limits; there is no free API key.
|
||||
LL2_API_TOKEN=
|
||||
|
||||
# Optional: OpenSky credentials JSON file path (alternative to above)
|
||||
# Optional: OpenSky credentials JSON file path, used when the two values above
|
||||
# are empty: the credentials.json your OpenSky account page downloads when you
|
||||
# create an API client. Use an absolute path for compatibility with launchers.
|
||||
# OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json
|
||||
|
||||
# Optional: dev server config
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
never derived: an aircraft that has not transmitted a rate reads as `null`
|
||||
rather than a level-flight `0`.
|
||||
|
||||
- Bound how often the GBFS proxy will relay. `/api/gbfs` already restricted
|
||||
where it could go — https, an allowlisted host, a station feed path, no
|
||||
redirects, a 12-second deadline and a 5 MB body cap — but nothing restricted
|
||||
how often, so the dev server would make unlimited requests at a third-party
|
||||
bikeshare operator on any caller's behalf. The route now admits 120
|
||||
requests/minute per client address, refusing the rest with 429 and
|
||||
`Retry-After` before any outbound fetch. The bikeshare layer needs about 15 a
|
||||
minute with every catalogued system in range, so ordinary use is unchanged.
|
||||
|
||||
- Add nine Ontario transit feeds: TTC, MiWay, Hamilton HSR, Durham Region,
|
||||
YRT/Viva, Guelph, Burlington, Barrie and Kingston, each carrying its own
|
||||
licence quote and attribution. A feed can now name its own modes, so Toronto's
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
|
||||
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
|
||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
|
||||
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
|
||||
- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
|
||||
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
|
||||
|
||||
@@ -343,6 +343,10 @@ Thirty tools, four jobs — the commands below come straight from the product's
|
||||
|
||||
**🎥 Direct it** — drone-operator camera verbs:
|
||||
|
||||
“Rotate the globe” spins Earth to the right continuously; ask for left, a speed,
|
||||
or stop. If it refuses, follow its reset-and-rotate or further-zoom guidance;
|
||||
camera input stops rotation.
|
||||
|
||||
> 🗣️ _"Take me to Tokyo."_ · _"Orbit around this area slowly."_ · _"Draw the walking route from the Capitol to Zilker Park."_ → _"Fly the route we just drew."_ · _"Zoom out to a globe view."_
|
||||
|
||||
**🖊️ Annotate it** — a whiteboard over the real world:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
- Add continuous globe rotation with direction and speed controls and a globe-fit
|
||||
check before starting (manjunath22466, #1225).
|
||||
@@ -0,0 +1,8 @@
|
||||
- OpenSky sign-in matches what OpenSky accepts since 2026-03-18. The server
|
||||
now reads `OPENSKY_CREDENTIALS_FILE` (the `credentials.json` OpenSky's
|
||||
account page downloads) on every platform, not only through the macOS
|
||||
launcher, and no longer sends retired username/password Basic auth:
|
||||
`OPENSKY_AUTH_MODE=basic` loads anonymously with one warning saying what to
|
||||
set instead (quanticsoul4772, #1229).
|
||||
- Credential-file warnings omit parser excerpts and filesystem error details;
|
||||
the path example also works with the macOS launchers.
|
||||
@@ -0,0 +1,4 @@
|
||||
- The terrain-heights bounds tests no longer read the checkout's
|
||||
`.gev-cache/terrain-heights.json`. Each test gets a cache file of its own, so
|
||||
`npm test` no longer fails after the dev server has cached terrain near
|
||||
Austin (KnottyDyes, #1244).
|
||||
@@ -0,0 +1,2 @@
|
||||
- Normalize station tags and retain valid mixed rows; skipped malformed rows still report degraded coverage (asturksever, #1268).
|
||||
- Correct Radio QA push-to-talk setup to target the mounted voice controller (asturksever, #1268).
|
||||
@@ -0,0 +1 @@
|
||||
- Respect provider cooldowns and bound tle groups (asturksever, #1270).
|
||||
@@ -0,0 +1 @@
|
||||
- Retire unavailable history and keep local trails (asturksever, #1273).
|
||||
@@ -0,0 +1 @@
|
||||
- Scan the kml root delimiter in bounded time (asturksever, #1274).
|
||||
@@ -1 +0,0 @@
|
||||
- Cull and budget surface height sampling (asturksever, #1277).
|
||||
@@ -0,0 +1 @@
|
||||
- Reserve singleflight before disk cache reads (asturksever, #1278).
|
||||
@@ -0,0 +1 @@
|
||||
- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279).
|
||||
@@ -0,0 +1,8 @@
|
||||
- Hold rendering only for animated content (asturksever, #1281).
|
||||
- Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281).
|
||||
- Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281).
|
||||
- Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281).
|
||||
- Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281).
|
||||
- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1281).
|
||||
- Intercept only Traffic provider URLs, leaving Cesium worker and module loading untouched (asturksever, #1281).
|
||||
- Settle an unfinished Traffic height correction when the last animated point leaves, so the layer stops reporting loading once its render hold is released (asturksever, #1281).
|
||||
@@ -0,0 +1,7 @@
|
||||
- Pause periodic network updates in hidden tabs; the overdue update on return restarts the polling schedule, so the next poll follows one interval later even when that update fails (asturksever, #1282).
|
||||
- Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282).
|
||||
- Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282).
|
||||
- Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282).
|
||||
- Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282).
|
||||
- Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282).
|
||||
- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282).
|
||||
@@ -0,0 +1,2 @@
|
||||
- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a
|
||||
cached model run (quanticsoul4772, #1303).
|
||||
@@ -0,0 +1,2 @@
|
||||
- Point distances wrap longitude differences across the antimeridian, in voice
|
||||
proximity checks and `places_nearby` (quanticsoul4772, #1304).
|
||||
@@ -0,0 +1,8 @@
|
||||
- Export the KartaView and Panoramax Street Level providers, so a host composing
|
||||
its own provider list can offer the keyless providers without Mapillary
|
||||
(samehkhamis, #1324).
|
||||
- A Mapillary source can take a `graphEndpoint`, a Graph API base that adds the
|
||||
token itself (such as a same-origin proxy). Its sequence lookups go there
|
||||
through the source's transport, and the photo viewer uses MapillaryJS's
|
||||
Graph provider at that endpoint, so no client token is needed in the page
|
||||
(samehkhamis, #1324).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Improve touch selection of aircraft and vessels through the shared contact
|
||||
picker, reveal tracked aircraft details when opening Context, support downward
|
||||
swipe dismissal of phone drawers while preserving scrolling and controls, and
|
||||
enlarge the phone logo (samehkhamis, #1338).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Reject impossible KartaView photo timestamps, so 31 February is unknown
|
||||
instead of rolling forward into March and passing the imagery date filter
|
||||
(Rudy Celekli, @rudycelekli, #1341).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Keep an impossible wildfire acquisition date off the detection clock, so a
|
||||
fire is not labeled with a later day it did not happen
|
||||
(Rudy Celekli, @rudycelekli, #1342).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Convert NHC cyclone movement speed from miles per hour to knots, so
|
||||
`movement.speedKt` in cyclone query results is no longer about 15% high
|
||||
(rudycelekli, #1350).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Keep a tracked vessel's speed, course, heading and fix time when AISStream
|
||||
static data arrives; the static message only merges name, type, destination
|
||||
and IMO (rudycelekli, #1351).
|
||||
- Apply changed static metadata immediately, retaining known fields when a
|
||||
later static report omits them, without refreshing the vessel's position
|
||||
(samehkhamis, #1351).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Read a destination heading that rounds to north as 000. 359.6° was shown as
|
||||
360° on the destination bearing while the compass tape already uses 000
|
||||
(Rudy Celekli, @rudycelekli, #1352).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Drop the decimal when fire radiative power rounds to 10 MW. A 9.96 MW
|
||||
detection was labeled 10.0 MW because the one-decimal cutoff ran before
|
||||
rounding (Rudy Celekli, @rudycelekli, #1354).
|
||||
+57
-16
@@ -1,5 +1,16 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
Civil aircraft share one delayed render timestamp per fleet update. Extrapolated
|
||||
fixes reuse their local coordinate frame until their coordinates change, including
|
||||
in-place ground-height corrections. Update cadence, contact counts, interpolation,
|
||||
turn rates and tracked-aircraft motion are unchanged. Cached frames are weakly
|
||||
keyed by each position fix and do not retain retired history.
|
||||
|
||||
Touch-first devices use three times the default Cesium zoom sensitivity, so
|
||||
pinching traverses globe and street scales with fewer gestures. Cesium retains
|
||||
its distance-aware zoom, collision handling and minimum/maximum distance limits.
|
||||
Fine-pointer desktop mouse and trackpad sensitivity is unchanged.
|
||||
|
||||
Analyst region queries resolve unique Natural Earth names offline without
|
||||
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
|
||||
Duplicate names require a geocoded containment anchor; when several entries for
|
||||
@@ -9,21 +20,15 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled
|
||||
lookups preserve the previous analyst cohort and boundary source. Navigation
|
||||
search keeps a successful geocode, with no search or view-based region recovery.
|
||||
|
||||
Screen annotations skip hidden records and, before surface sampling, reject an
|
||||
unsettled anchor only when its whole accepted height range (-430 to 9000 m) is
|
||||
behind the camera or below the globe horizon; a settled height is tested as the
|
||||
actual point. Anchors outside the conservative frustum still project at their
|
||||
cached or zero height, so a partly visible arrow keeps its connector, but do
|
||||
not sample. Unresolved visible heights share a 32-sample projection-pass budget,
|
||||
served oldest-refused first, retry at most once per second per anchor on
|
||||
rendered frames, and retain no extra timer or listener. Validated heights remain
|
||||
settled in the bounded cache.
|
||||
Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh and restarts that layer's polling schedule from the return, so the next poll follows one interval later whether or not the catch-up update succeeds; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals.
|
||||
|
||||
Terrain height requests accept at most 64 points, matching the browser service's
|
||||
existing batch size and one bounded upstream chunk. Larger requests return HTTP
|
||||
413 before disk-cache loading or upstream work; malformed coordinates remain 400.
|
||||
The browser continues to split larger workloads into sequential 64-point requests.
|
||||
|
||||
Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
|
||||
|
||||
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
|
||||
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
|
||||
canonical; `greatCircleMeters` is its exact metre conversion. Missing,
|
||||
@@ -423,6 +428,8 @@ than relabeling older geometry. The cone represents forecast center uncertainty,
|
||||
not storm size or the full hazard area. Successful empty and unavailable states
|
||||
are distinct. One owned native Cesium data source preserves polygon holes and
|
||||
geographic seams and releases on disable; forecast animation is not implied.
|
||||
NHC movement speed is converted from miles per hour to rounded knots by the
|
||||
proxy; advisory wind intensity is already in knots and is passed through.
|
||||
Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source.
|
||||
Consult the linked official advisory for safety decisions.
|
||||
|
||||
@@ -459,6 +466,14 @@ than a reading. Heading keeps its existing separate check for its own sentinel.
|
||||
Genuine readings are unchanged, including a stopped vessel's zero and the
|
||||
highest speed and course the fields can encode.
|
||||
|
||||
For an already tracked vessel, AISStream static messages update name, type,
|
||||
destination and IMO without changing its position, motion, fix timestamps,
|
||||
position freshness or trail. Non-empty static values replace earlier values;
|
||||
omitted, empty or unavailable fields retain known metadata across Class B's
|
||||
separate reports and in the cache used by the next position update. Position
|
||||
reports still prefer their own metadata name. Invalid Class B parts are ignored;
|
||||
absent fields prefer the latest live value over the static cache.
|
||||
|
||||
Native `<select>` controls declare a dark colour scheme and explicit option
|
||||
colours, so the browser-painted option list matches the panel it drops out of.
|
||||
The scheme is declared on the controls rather than on the document root, so
|
||||
@@ -1396,11 +1411,26 @@ shows a bottom tab bar: Layers (Data Layers, with Scenes collapsed below),
|
||||
Cameras (CCTV and Street Level), Context (Global Context, plus Weather and
|
||||
Recent Imagery when shown), Search (Location) and More (Visual Presets and Map
|
||||
Source above Display). A tab opens one bottom sheet; the same tab, the backdrop
|
||||
or Escape closes it, and focus moves into the sheet and back to its tab. Panels
|
||||
or Escape closes it, and focus moves into the sheet and back to its tab. A
|
||||
downward swipe of at least 64px on non-control sheet content also closes it,
|
||||
when every scroll container under the finger starts at the top. Ordinary
|
||||
vertical scrolling, horizontal gestures, controls and multitouch stay native;
|
||||
a cancelled gesture or a sheet/viewport change cannot dismiss another sheet. Panels
|
||||
are not moved in the DOM: `styles/mobile-shell.css` presents the rails and dock
|
||||
as the sheet, and the dock's Location and Presets trays render inline. The voice
|
||||
control floats above the tab bar while no sheet is open.
|
||||
|
||||
The phone logo is 58px wide (about 39px high), aligned with the top controls.
|
||||
The shared contact picker preserves exact picks. For a coarse primary pointer
|
||||
that misses onto the map surface, it searches at most eight hits in a 14px square
|
||||
and accepts the first registered layer hit, stopping at non-map objects. Civil
|
||||
and military aircraft and vessels share this rule; fine pointers stay exact.
|
||||
|
||||
Manually opening Context with a tracked civilian or military aircraft enters
|
||||
the existing Contacts mode when Context is off. Contacts adopts that aircraft
|
||||
without restarting its tracking; an active or entering Space Missions mode is
|
||||
left alone. Programmatic sheet changes and closing the tab do not activate a mode.
|
||||
|
||||
Sheets borrow panels. Opening one expands its panels without persisting them or
|
||||
claiming a user choice, and share links keep reporting each borrowed panel's
|
||||
state from before the sheet; closing puts back what opening changed, while a
|
||||
@@ -1901,6 +1931,10 @@ Updated: September 23, 2026
|
||||
|
||||
## Aircraft and vessel server modules
|
||||
|
||||
OpenSky credential-file failures emit a fixed warning once per process; neither
|
||||
JSON parser excerpts nor filesystem error messages are logged. The environment
|
||||
example uses an absolute path compatible with the macOS launchers.
|
||||
|
||||
Standalone aircraft routes now live in `server/providers/aircraft/`: OpenSky
|
||||
state vectors and regional fallback, adsb.lol military positions, ADSBDB
|
||||
enrichment and track backfill. Vessel routes and websocket/watchdog setup live
|
||||
@@ -4202,6 +4236,8 @@ animation. Polls, surface completions and trail fades request individual renders
|
||||
Disable clears vehicles, detection references, requests, timers and holds;
|
||||
destruction also releases collections and registrations.
|
||||
|
||||
The `scripts/qa-layer-visibility.mjs` gate uses the development-only registration seam and real browser tab visibility to prove periodic work pauses while hidden, explicit refresh still works, resume performs one overdue refresh, and disabled layers stay stopped.
|
||||
|
||||
**Acceptance harness.** Run against an already running development server:
|
||||
|
||||
```sh
|
||||
@@ -4618,7 +4654,7 @@ silently demoting every later lookup for the session.
|
||||
multi-sensor datasheets publish a capability range, not the lens's current zoom, so those
|
||||
cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public
|
||||
camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452).
|
||||
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP.
|
||||
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. Fire-power labels share display rounding before localization: one decimal below the rounded 10 MW boundary, then whole megawatts (for example, 9.96 MW reads 10 MW).
|
||||
- **CCTV v2 foundation:** a pitched
|
||||
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
|
||||
far cap, retargeting the existing video/canvas texture pipeline. Stills are drawn at their source aspect, centered on the placeholder dark (`#01070c`), instead of being stretched to the 16:9 canvas. Manual calibration only —
|
||||
@@ -4721,7 +4757,7 @@ silently demoting every later lookup for the session.
|
||||
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
|
||||
Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
|
||||
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
|
||||
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket) and `/api/military/track` (tar1090 readsb, ~24h history, ODbL — credit adsb.lol).
|
||||
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request.
|
||||
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
|
||||
|
||||
### Optional Overpass configuration
|
||||
@@ -4979,7 +5015,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on
|
||||
> `main` + 26 route pins; the same tree on Node 24 reports 2,295.
|
||||
|
||||
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation.
|
||||
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee.
|
||||
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
|
||||
- **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped.
|
||||
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
|
||||
@@ -5285,7 +5321,7 @@ pins and share links already supply complete boxes.
|
||||
- `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded.
|
||||
- `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity.
|
||||
- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors.
|
||||
- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (60s cache, 5MB cap, ODbL attribution required in UI).
|
||||
- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (400 for an invalid hex, otherwise `503 history_unavailable` with `Retry-After: 1800` and `Cache-Control: no-store`, no upstream request; military tracking uses its locally accumulated trail).
|
||||
- Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped.
|
||||
- `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message.
|
||||
|
||||
@@ -5372,7 +5408,7 @@ pins and share links already supply complete boxes.
|
||||
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Scenes keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
|
||||
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
|
||||
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
|
||||
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
|
||||
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
|
||||
- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition.
|
||||
- **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown.
|
||||
- **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material.
|
||||
@@ -5500,6 +5536,11 @@ easier to meet (detection is now on more often), but does not create it.
|
||||
short-way-round longitude span and wraps the padded edges, so a dateline-
|
||||
crossing box stays its true width. Raw subtraction inflated a 0.41° metro box
|
||||
to 86.7° and a 60° territory to 132°.
|
||||
- **Point distances take the short way round 180°.** The annotation
|
||||
resolver's `approximateDistanceM` (voice "point out" proximity checks) and
|
||||
`places_nearby` distances wrap the longitude difference. Raw subtraction put
|
||||
Savusavu 38,148 km from a camera over the Lau Islands, so the proximity check
|
||||
refused it, and sorted a place 2 km across 180° last.
|
||||
- CCTV calibration persists at `godsEyeView.cctv.calibration.v2` (v2 rebuild;
|
||||
the store was wiped clean, no import from the old `v1` key).
|
||||
- CCTV v3 floor QA pins are: zero samples for heading-only edits; zero transient
|
||||
@@ -5996,7 +6037,7 @@ closed. Performance and live agency reliability require recorded validation.
|
||||
|
||||
## Wind forecast models
|
||||
|
||||
Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results.
|
||||
Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results. Last-good data is marked stale; the wind row shows it, and `get_wind` returns `stale` and adds "(forecast may be stale)" to its summary.
|
||||
|
||||
### Local Weather review candidate
|
||||
|
||||
|
||||
+15
-9
@@ -3,10 +3,11 @@
|
||||
God's Eye View uses explicit auth modes for `/api/flights`:
|
||||
|
||||
- `OPENSKY_AUTH_MODE=oauth` (default, recommended)
|
||||
- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback)
|
||||
- `OPENSKY_AUTH_MODE=basic`
|
||||
- `OPENSKY_AUTH_MODE=auto` (OAuth when configured, otherwise anonymous)
|
||||
- `OPENSKY_AUTH_MODE=anon`
|
||||
|
||||
`OPENSKY_AUTH_MODE=basic` is retired (see below).
|
||||
|
||||
Reference: OpenSky REST API docs recommend OAuth2 Client Credentials flow.
|
||||
|
||||
## Quick Start (OAuth Client JSON)
|
||||
@@ -41,13 +42,18 @@ Launchers resolve OAuth creds in this order:
|
||||
2. `OPENSKY_CREDENTIALS_FILE`
|
||||
3. Keychain (`opensky-network` / `client_id`, `client_secret`)
|
||||
|
||||
## Basic Auth (Legacy)
|
||||
The server itself reads 1 and 2, so `npm run dev`, `npm run preview` and
|
||||
Pinokio also work with only `OPENSKY_CREDENTIALS_FILE` set in `.env`, on any
|
||||
platform. It reads the file on each token refresh.
|
||||
|
||||
Basic auth is still supported for now, but OpenSky is moving away from it.
|
||||
## Basic Auth (Retired)
|
||||
|
||||
```bash
|
||||
OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh
|
||||
```
|
||||
OpenSky stopped accepting username/password (HTTP Basic) sign-in on
|
||||
2026-03-18; its API now takes only OAuth2 client credentials. With
|
||||
`OPENSKY_AUTH_MODE=basic` the server no longer sends `OPENSKY_USERNAME` /
|
||||
`OPENSKY_PASSWORD`: it requests anonymously (reason `basic_retired`) and
|
||||
logs once what to set instead. Create an API client on your OpenSky
|
||||
account page and use its `credentials.json` as above.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -61,6 +67,6 @@ Useful reasons:
|
||||
|
||||
- `oauth_invalid_or_missing`: OAuth client not found/usable
|
||||
- `oauth_invalid_credentials`: OAuth client rejected by OpenSky
|
||||
- `basic_invalid_credentials`: username/password rejected
|
||||
- `missing_basic_creds`: basic mode missing user/pass
|
||||
- `missing_oauth_creds`: auto mode found no OAuth client
|
||||
- `basic_retired`: basic mode requested; answered anonymously
|
||||
- `rate_limited`: OpenSky throttling
|
||||
@@ -272,6 +272,8 @@
|
||||
"./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"./layers/street-level/google": "./src/layers/streetLevel/providers/google/index.js",
|
||||
"./layers/street-level/kartaview": "./src/layers/streetLevel/providers/kartaview/index.js",
|
||||
"./layers/street-level/panoramax": "./src/layers/streetLevel/providers/panoramax/index.js",
|
||||
"./tools": "./src/tools/index.js",
|
||||
"./tools/mcp": "./src/tools/mcp/index.js",
|
||||
"./tools/panel": "./src/tools/panel.js",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts/qa-form-fields.mjs",
|
||||
"scripts/package-boundaries.json",
|
||||
"scripts/format.mjs",
|
||||
"scripts/qa-layer-visibility.mjs",
|
||||
"scripts/next-layer-state-token.mjs",
|
||||
"scripts/check-layer-state-tokens.mjs",
|
||||
"scripts/qa-layer-token-twochar.mjs",
|
||||
@@ -1086,5 +1087,7 @@
|
||||
"src/ui/statusBadge.test.mjs",
|
||||
"src/ui/cctvBadges.test.mjs",
|
||||
"scripts/qa-status-badges.mjs",
|
||||
"src/dockerTrustedPeers.test.mjs"
|
||||
"src/dockerTrustedPeers.test.mjs",
|
||||
"src/cameraGlobeRotation.test.mjs",
|
||||
"src/voice/globeResetConfirmation.test.mjs"
|
||||
]
|
||||
@@ -103,6 +103,9 @@
|
||||
"modules": [
|
||||
"server/providers/space.js",
|
||||
"server/providers/space/celestrak.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"src/localRequestGate.mjs",
|
||||
"server/providers/space/cooldown.js",
|
||||
"server/providers/space/launch-library.js",
|
||||
"server/providers/common/http.js",
|
||||
"src/data/spaceProviderRequests.js",
|
||||
@@ -1427,7 +1430,9 @@
|
||||
"./layers/street-level",
|
||||
"./layers/street-level/mapillary",
|
||||
"./layers/street-level/mapillary/source",
|
||||
"./layers/street-level/google"
|
||||
"./layers/street-level/google",
|
||||
"./layers/street-level/kartaview",
|
||||
"./layers/street-level/panoramax"
|
||||
],
|
||||
"modules": [
|
||||
"src/layers/streetLevel/index.js",
|
||||
@@ -3667,6 +3672,7 @@
|
||||
"src/data/labelArbiter.js",
|
||||
"src/data/meshFloorSampler.js",
|
||||
"src/data/pickRegistry.js",
|
||||
"src/data/scenePick.js",
|
||||
"src/data/spriteOrder.js",
|
||||
"src/data/terrainHeights.js",
|
||||
"src/data/transit.js",
|
||||
|
||||
@@ -18,3 +18,30 @@ export function chromeExecutablePath({
|
||||
if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH;
|
||||
return exists(MACOS_CHROME) ? MACOS_CHROME : undefined;
|
||||
}
|
||||
|
||||
/** Restore an owned test window before capture: focus emulation alone does not
|
||||
* unminimize the native compositor, so CDP screenshots can otherwise stall. */
|
||||
export async function captureQaScreenshot(
|
||||
page,
|
||||
options,
|
||||
{ headful = true } = {},
|
||||
) {
|
||||
if (headful) {
|
||||
const session = await page.createCDPSession();
|
||||
try {
|
||||
const { windowId, bounds } = await session.send(
|
||||
'Browser.getWindowForTarget',
|
||||
);
|
||||
if (bounds.windowState === 'minimized') {
|
||||
await session.send('Browser.setWindowBounds', {
|
||||
windowId,
|
||||
bounds: { windowState: 'normal' },
|
||||
});
|
||||
}
|
||||
await page.bringToFront();
|
||||
} finally {
|
||||
await session.detach();
|
||||
}
|
||||
}
|
||||
return page.screenshot(options);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Real-browser proof for manager-owned periodic work and document visibility.
|
||||
* Uses the app's development-only layer registration seam; no provider keys.
|
||||
* Run: node scripts/qa-layer-visibility.mjs --url http://localhost:4173
|
||||
* Hiding uses a real background tab, never a patched document.hidden getter.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const urlIndex = args.indexOf('--url');
|
||||
const url = new URL(
|
||||
(urlIndex >= 0 ? args[urlIndex + 1] : undefined) ||
|
||||
process.env.QA_BASE_URL ||
|
||||
'http://localhost:4173',
|
||||
);
|
||||
url.searchParams.set('welcome', '0');
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: chromeExecutablePath(),
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
});
|
||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const check = (name, actual, expected) => {
|
||||
assert.equal(actual, expected, name);
|
||||
console.log(`PASS ${name}: ${actual}`);
|
||||
};
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const focus = await page.createCDPSession();
|
||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() => window.__gevQaRegisterLayer && window.__godsEyeView?.dataManager,
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
window.__qaVisibilityUpdates = 0;
|
||||
window.__gevQaRegisterLayer(manager, {
|
||||
id: 'qa-document-visibility',
|
||||
name: 'Visibility QA',
|
||||
updateInterval: 750,
|
||||
init() {},
|
||||
enable() {},
|
||||
disable() {},
|
||||
destroy() {},
|
||||
async update() {
|
||||
window.__qaVisibilityUpdates++;
|
||||
return true;
|
||||
},
|
||||
getStats() {
|
||||
return { count: window.__qaVisibilityUpdates };
|
||||
},
|
||||
});
|
||||
await manager.setEnabled('qa-document-visibility', true, { origin: 'qa' });
|
||||
});
|
||||
const count = () => page.evaluate(() => window.__qaVisibilityUpdates);
|
||||
await page.waitForFunction(() => window.__qaVisibilityUpdates >= 3);
|
||||
check('periodic updates run while visible', (await count()) >= 3, true);
|
||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
|
||||
const cover = await browser.newPage();
|
||||
await cover.bringToFront();
|
||||
await page.waitForFunction(() => document.visibilityState === 'hidden');
|
||||
const hiddenCount = await count();
|
||||
await pause(2200);
|
||||
check('hidden tab makes no periodic updates', await count(), hiddenCount);
|
||||
check(
|
||||
'explicit refresh remains available while hidden',
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.refreshLayer('qa-document-visibility'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
check('explicit hidden refresh runs once', await count(), hiddenCount + 1);
|
||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.visibilityState === 'visible' &&
|
||||
window.__qaVisibilityUpdates > 0,
|
||||
);
|
||||
await pause(200);
|
||||
check(
|
||||
'resume performs one overdue refresh without a catch-up burst',
|
||||
await count(),
|
||||
hiddenCount + 2,
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled(
|
||||
'qa-document-visibility',
|
||||
false,
|
||||
{ origin: 'qa' },
|
||||
),
|
||||
);
|
||||
const stopped = await count();
|
||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
|
||||
await cover.bringToFront();
|
||||
await page.waitForFunction(() => document.visibilityState === 'hidden');
|
||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||
await pause(1100);
|
||||
check(
|
||||
'disabled layer does not resume on visibility change',
|
||||
await count(),
|
||||
stopped,
|
||||
);
|
||||
check(
|
||||
'QA layer destroys cleanly',
|
||||
await page.evaluate(() =>
|
||||
window.__gevQaUnregisterLayer(
|
||||
window.__godsEyeView.dataManager,
|
||||
'qa-document-visibility',
|
||||
),
|
||||
),
|
||||
true,
|
||||
);
|
||||
console.log('RESULT: 7 passed');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -2759,13 +2759,16 @@ async function main() {
|
||||
const beforeSpace = radio.getUIState().audioState;
|
||||
// Simulate an already-started hold-Space session; click-started open mic
|
||||
// deliberately ignores Space takeover, and short taps never claim voice.
|
||||
const priorPushToTalkMode = voice.pushToTalkMode;
|
||||
voice.pushToTalkMode = true;
|
||||
// The provider facade forwards reads, not writes. Set the mounted
|
||||
// controller's input owner; assigning on the facade only shadows it.
|
||||
const controller = voice.session.adapter.controller;
|
||||
const priorPushToTalkMode = controller.pushToTalkMode;
|
||||
controller.pushToTalkMode = true;
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, code: 'Space', key: ' ' }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
const afterSpace = radio.getUIState().audioState;
|
||||
document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, code: 'Space', key: ' ' }));
|
||||
voice.pushToTalkMode = priorPushToTalkMode;
|
||||
controller.pushToTalkMode = priorPushToTalkMode;
|
||||
await radio.play();
|
||||
voice.setVoiceSpeaker('ai');
|
||||
return { beforeSpace, afterSpace, afterAi: radio.getUIState().audioState };
|
||||
|
||||
+57
-55
@@ -25,12 +25,13 @@
|
||||
* Visual proof saved to qa-shots/ (gitignored).
|
||||
*
|
||||
* Run: node scripts/qa-traffic.mjs --url http://localhost:4410
|
||||
* Add --fixtures for synthetic roads and the recorded flow tile without a key;
|
||||
* that mode does not qualify live source access or quota accounting.
|
||||
* Add --fixtures for synthetic road/flow tiles and a flat terrain surface without a key;
|
||||
* that mode does not qualify geographic terrain, live source access or quota accounting.
|
||||
* Exits non-zero on any FAIL. Does not commit anything.
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import { captureQaScreenshot } from './qa-chrome.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -44,10 +45,12 @@ const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4410');
|
||||
const appUrl = new URL(getOpt('--url', 'http://localhost:4410'));
|
||||
appUrl.searchParams.set('welcome', '0');
|
||||
const APP_URL = appUrl.href;
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
const FIXTURES = argv.includes('--fixtures');
|
||||
const fixtureResponse = FIXTURES ? (await import('./traffic-fixtures.mjs')).trafficFixtureResponse : () => null;
|
||||
const { installTrafficRoutes } = await import('./traffic-fixtures.mjs');
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
@@ -80,6 +83,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Enable traffic + teleport, then poll the layer until settled. */
|
||||
async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
|
||||
// The app deliberately stops rendering hidden pages. Programmatic camera
|
||||
// changes do not activate a headful window the way a real UI click does.
|
||||
await page.bringToFront();
|
||||
await page.waitForFunction(() => document.visibilityState === 'visible');
|
||||
return page.evaluate(async (v, minC, tS) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const dm = gev.dataManager;
|
||||
@@ -97,6 +104,7 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
|
||||
});
|
||||
let s = null;
|
||||
for (let i = 0; i < tS; i++) {
|
||||
if (document.visibilityState !== 'visible') throw new Error('Traffic QA page became hidden; rendering is intentionally paused');
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
s = mod.getStats();
|
||||
// Retained roads from the previous city can satisfy count/loading before
|
||||
@@ -122,8 +130,8 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||
if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.');
|
||||
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||
if (FIXTURES) console.log('Source mode: synthetic road/flow tiles and a flat terrain surface; live quota is not qualified.');
|
||||
if (!statusBefore?.hasKey) {
|
||||
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
|
||||
process.exitCode = 2;
|
||||
@@ -135,8 +143,10 @@ async function main() {
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--no-sandbox', '--disable-setuid-sandbox',
|
||||
...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []),
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1440,900',
|
||||
],
|
||||
@@ -144,24 +154,22 @@ async function main() {
|
||||
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const onFixture = (request) => {
|
||||
const response = fixtureResponse(request);
|
||||
if (response) void request.respond(response); else void request.continue();
|
||||
};
|
||||
if (FIXTURES) { await page.setRequestInterception(true); page.on('request', onFixture); }
|
||||
let page = await browser.newPage();
|
||||
// Keep this visible-page regression deterministic even if the OS starts
|
||||
// Chrome minimized. Use browser focus emulation, not patched DOM getters.
|
||||
const focusSession = await page.createCDPSession();
|
||||
await focusSession.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
|
||||
// Track flow-tile requests + traffic console lines for (ii)/(iii)/(v).
|
||||
// Track flow-tile requests for the (iii) budget check.
|
||||
const flowRequests = [];
|
||||
page.on('request', (req) => {
|
||||
if (req.url().includes('/api/tomtom/flow/')) flowRequests.push(req.url());
|
||||
});
|
||||
const trafficLogs = [];
|
||||
page.on('console', (msg) => {
|
||||
const t = msg.text();
|
||||
if (t.includes('[Data:Traffic]')) trafficLogs.push(t);
|
||||
});
|
||||
const pageErrors = [];
|
||||
if (FIXTURES) await installTrafficRoutes(page, { origin: appUrl.origin, fixtures: true, onError: error => pageErrors.push(error.message) });
|
||||
page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); });
|
||||
page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); });
|
||||
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
@@ -194,7 +202,7 @@ async function main() {
|
||||
`coverage=${mumbai.flowCoveragePct}% tiles=${mumbai.tilesFetched}`);
|
||||
if (mumbai.mode !== 'live' || !(mumbai.count > 0) || !(colored > 0)) exitCode = 1;
|
||||
await sleep(1200);
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-flow.png') });
|
||||
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }, { headful: HEADFUL });
|
||||
}
|
||||
|
||||
// ── (iv) uncovered-roads param — hide vs sim ─────────────────────────────
|
||||
@@ -227,7 +235,7 @@ async function main() {
|
||||
(hid.flowBuckets?.sim || 0) === 0 && hid.count > 0,
|
||||
`count=${hid.count} sim=${hid.flowBuckets?.sim}`);
|
||||
if ((hid.flowBuckets?.sim || 0) !== 0) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') });
|
||||
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') }, { headful: HEADFUL });
|
||||
} else {
|
||||
record('PARAM: hide mode renders zero sim (white) dots', null,
|
||||
'view had 100% coverage (no sim dots to hide) — inconclusive here, covered by unit tests');
|
||||
@@ -235,30 +243,26 @@ async function main() {
|
||||
|
||||
// ── (ii) C4 oblique bounds ───────────────────────────────────────────────
|
||||
console.log('\n(ii) C4 — oblique fetch bounds land at the look-at point...');
|
||||
trafficLogs.length = 0;
|
||||
await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 });
|
||||
const oblique = await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 });
|
||||
{
|
||||
const fetchLine = trafficLogs.find((t) => t.includes('fetch') && t.includes('['));
|
||||
let ok = false; let detail = 'no fetch log captured';
|
||||
if (fetchLine) {
|
||||
const m = fetchLine.match(/\[(-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\]/);
|
||||
if (m) {
|
||||
const [s, w, n, e] = m.slice(1).map(Number);
|
||||
const cLat = (s + n) / 2; const cLon = (w + e) / 2;
|
||||
const dKm = Math.sqrt(((cLat - 30.245) * 111) ** 2 + ((cLon - (-97.72)) * 111 * Math.cos(30.27 * Math.PI / 180)) ** 2);
|
||||
ok = dKm > 1 && dKm <= 13; // look-at ~6.9 km ahead; clamp allows ≤12 (+margin)
|
||||
detail = `box center (${cLat.toFixed(4)},${cLon.toFixed(4)}) is ${dKm.toFixed(1)} km from camera (want 1–13 km; pre-fix bug: 25+ km)`;
|
||||
}
|
||||
const bounds = oblique?.roadBounds;
|
||||
let ok = false; let detail = 'no road bounds published';
|
||||
if (bounds) {
|
||||
const cLat = (bounds.south + bounds.north) / 2;
|
||||
const cLon = (bounds.west + bounds.east) / 2;
|
||||
const dKm = Math.hypot((cLat - 30.245) * 111, (cLon + 97.72) * 111 * Math.cos(30.27 * Math.PI / 180));
|
||||
ok = dKm > 1 && dKm <= 13;
|
||||
detail = `road box center is ${dKm.toFixed(1)} km from camera (want 1–13 km)`;
|
||||
}
|
||||
record('C4: oblique fetch box centers on the look-at point', ok, detail);
|
||||
if (!ok) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') });
|
||||
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') }, { headful: HEADFUL });
|
||||
}
|
||||
|
||||
// ── (iii) budget honesty ─────────────────────────────────────────────────
|
||||
console.log('\n(iii) Budget — dailyCount grew by ≤ requests this run...');
|
||||
{
|
||||
const statusAfter = FIXTURES ? statusBefore : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json());
|
||||
const statusAfter = FIXTURES ? statusBefore : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json());
|
||||
const grew = statusAfter.dailyCount - statusBefore.dailyCount;
|
||||
const ok = grew >= 0 && grew <= flowRequests.length;
|
||||
record('BUDGET: /api/tomtom/status growth ≤ page tile requests', FIXTURES ? null : ok,
|
||||
@@ -268,25 +272,22 @@ async function main() {
|
||||
|
||||
// ── (v) KEYLESS fallback (intercepted — server key untouched) ────────────
|
||||
console.log('\n(v) KEYLESS — intercepted status, expect pure simulation...');
|
||||
if (FIXTURES) page.off('request', onFixture);
|
||||
await page.setRequestInterception(true);
|
||||
// Keyless is an independent fresh-session contract. Close the keyed page
|
||||
// so its workers, terrain work and intercepted cache cannot leak into it.
|
||||
await page.close();
|
||||
page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
const keylessFocus = await page.createCDPSession();
|
||||
await keylessFocus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||
page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); });
|
||||
page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); });
|
||||
const keylessFlowReqs = [];
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
if (url.includes('/api/tomtom/status')) {
|
||||
req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ hasKey: false }) });
|
||||
return;
|
||||
}
|
||||
if (url.includes('/api/tomtom/flow/')) {
|
||||
keylessFlowReqs.push(url);
|
||||
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
|
||||
return;
|
||||
}
|
||||
const response = fixtureResponse(req);
|
||||
if (response) { void req.respond(response); return; }
|
||||
try { req.continue(); } catch { /* already handled */ }
|
||||
await installTrafficRoutes(page, {
|
||||
origin: appUrl.origin, fixtures: FIXTURES, keyless: true,
|
||||
onFlow: url => keylessFlowReqs.push(url),
|
||||
onError: error => pageErrors.push(error.message),
|
||||
});
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
@@ -316,8 +317,9 @@ async function main() {
|
||||
exitCode = 1;
|
||||
}
|
||||
await sleep(1000);
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') });
|
||||
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }, { headful: HEADFUL });
|
||||
}
|
||||
record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; '));
|
||||
} catch (e) {
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
||||
exitCode = 3;
|
||||
|
||||
@@ -47,6 +47,7 @@ export async function reportTransitVisibility(page, label) {
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest();
|
||||
return {
|
||||
pageVisibility: document.visibilityState,
|
||||
total: state._vehicles.size,
|
||||
visible: state._visible.size,
|
||||
moving: state._moving.size,
|
||||
@@ -59,6 +60,10 @@ export async function reportTransitVisibility(page, label) {
|
||||
};
|
||||
});
|
||||
console.log(` ${label}: ${JSON.stringify(result)}`);
|
||||
if (result.pageVisibility !== 'visible')
|
||||
throw new Error(
|
||||
`${label}: Transit QA requires a visible page; hidden pages deliberately pause polling`,
|
||||
);
|
||||
if (result.renderLoop === false || result.renderErrors.length)
|
||||
throw new Error(
|
||||
`${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
export const BOSTON = {
|
||||
id: 'boston',
|
||||
feedId: 'mbta',
|
||||
label: 'Boston (MBTA)',
|
||||
lat: 42.3554,
|
||||
lon: -71.0605,
|
||||
@@ -11,6 +12,7 @@ const LIVE_CITIES = [
|
||||
BOSTON,
|
||||
{
|
||||
id: 'austin',
|
||||
feedId: 'capmetro-austin',
|
||||
label: 'Austin (CapMetro)',
|
||||
lat: 30.2672,
|
||||
lon: -97.7431,
|
||||
@@ -19,6 +21,7 @@ const LIVE_CITIES = [
|
||||
},
|
||||
{
|
||||
id: 'minneapolis',
|
||||
feedId: 'metrotransit-msp',
|
||||
label: 'Minneapolis (Metro Transit)',
|
||||
lat: 44.9778,
|
||||
lon: -93.265,
|
||||
@@ -27,6 +30,7 @@ const LIVE_CITIES = [
|
||||
},
|
||||
{
|
||||
id: 'helsinki',
|
||||
feedId: 'hsl-helsinki',
|
||||
label: 'Helsinki (HSL)',
|
||||
lat: 60.1699,
|
||||
lon: 24.9384,
|
||||
@@ -35,6 +39,7 @@ const LIVE_CITIES = [
|
||||
},
|
||||
{
|
||||
id: 'oslo',
|
||||
feedId: 'entur-norway',
|
||||
label: 'Oslo (Entur)',
|
||||
lat: 59.9139,
|
||||
lon: 10.7522,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Camera-jump regression against an existing server; never starts a server. */
|
||||
import puppeteer from 'puppeteer';
|
||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
|
||||
|
||||
@@ -307,7 +307,7 @@ try {
|
||||
result.pixelPass = pixels.pass;
|
||||
result.pixelDetails = pixels;
|
||||
result.errors = errors;
|
||||
await page.screenshot({ path: `${shots}/recovery.png` });
|
||||
await captureQaScreenshot(page, { path: `${shots}/recovery.png` });
|
||||
await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2));
|
||||
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
|
||||
const pass =
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { captureQaScreenshot } from './qa-chrome.mjs';
|
||||
import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js';
|
||||
|
||||
// Not registered or active: camera discovery and live polls never own this feed.
|
||||
@@ -219,7 +220,7 @@ export async function runFleetBudgets({
|
||||
allocations.layerFrameBytes / allocationTrace.rows.length <= 8192,
|
||||
`${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`,
|
||||
);
|
||||
await page.screenshot({
|
||||
await captureQaScreenshot(page, {
|
||||
path: `${shots}/${tag}-fleet-${count}.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 85,
|
||||
@@ -392,7 +393,7 @@ export async function runBostonMatrix({
|
||||
JSON.stringify({ pixels, contrast }),
|
||||
{ unexercised: pixels.length < 6 },
|
||||
);
|
||||
await page.screenshot({
|
||||
await captureQaScreenshot(page, {
|
||||
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 90,
|
||||
@@ -407,7 +408,7 @@ export async function runBostonMatrix({
|
||||
target.key,
|
||||
);
|
||||
await wait(500);
|
||||
await page.screenshot({
|
||||
await captureQaScreenshot(page, {
|
||||
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 90,
|
||||
@@ -431,7 +432,7 @@ export async function runBostonMatrix({
|
||||
);
|
||||
}
|
||||
await wait(300);
|
||||
await page.screenshot({
|
||||
await captureQaScreenshot(page, {
|
||||
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 90,
|
||||
@@ -579,7 +580,7 @@ export async function runBostonLive({
|
||||
JSON.stringify(selection),
|
||||
{ unexercised: !target },
|
||||
);
|
||||
await page.screenshot({
|
||||
await captureQaScreenshot(page, {
|
||||
path: `${shots}/${tag}-boston-live-${view.name}.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 90,
|
||||
@@ -1010,7 +1011,7 @@ export async function runTrailVisibility({
|
||||
stable: pixels.stable[i],
|
||||
eligible: pixels.eligible[i],
|
||||
}));
|
||||
await page.screenshot({
|
||||
await captureQaScreenshot(page, {
|
||||
path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 95,
|
||||
|
||||
+67
-20
@@ -36,10 +36,11 @@ import {
|
||||
*/
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
|
||||
import { createTransitHistory } from '../server/providers/transitHistory.js';
|
||||
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
|
||||
import { transitModeTier } from '../src/data/transitPresetStyle.js';
|
||||
import { TRANSIT_POLL_MS } from '../src/layers/transit/policy.js';
|
||||
|
||||
import {
|
||||
reduceAnchor,
|
||||
@@ -264,6 +265,7 @@ const browser = await puppeteer.launch({
|
||||
executablePath: chromeExecutablePath(),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1920,1080',
|
||||
@@ -271,6 +273,10 @@ const browser = await puppeteer.launch({
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
// This gate measures visible-page motion. OS minimization must not silently
|
||||
// switch it into the app's intentional hidden-tab polling pause.
|
||||
const focus = await page.createCDPSession();
|
||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||
boundPageEvaluations(page);
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
const pageErrors = [];
|
||||
@@ -286,11 +292,13 @@ page.on('request', (request) => {
|
||||
});
|
||||
|
||||
const shot = (name, caption) =>
|
||||
page
|
||||
.screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 })
|
||||
.then(() => {
|
||||
console.log(` shot ${name} — ${caption}`);
|
||||
});
|
||||
captureQaScreenshot(page, {
|
||||
path: `${SHOTS}/${name}`,
|
||||
type: 'jpeg',
|
||||
quality: 82,
|
||||
}).then(() => {
|
||||
console.log(` shot ${name} — ${caption}`);
|
||||
});
|
||||
|
||||
const view = (city) =>
|
||||
`${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`;
|
||||
@@ -389,22 +397,42 @@ try {
|
||||
orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
}, city);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(window.__godsEyeView.dataManager.layers
|
||||
.get('transit')
|
||||
.module.getStats().count || 0) > 0,
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
const arrived = await page
|
||||
.waitForFunction(
|
||||
(feedId) =>
|
||||
[
|
||||
...window.__godsEyeView.dataManager.layers
|
||||
.get('transit')
|
||||
.module._transitStateForTest()
|
||||
._vehicles.keys(),
|
||||
].some((key) => key.startsWith(`${feedId}:`)),
|
||||
{ timeout: 90_000 },
|
||||
city.feedId,
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (!arrived) {
|
||||
check(
|
||||
`${city.label}: live feed arrived`,
|
||||
false,
|
||||
`No vehicles from ${city.feedId}; retained vehicles from other cities cannot qualify this view`,
|
||||
{ unexercised: true },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await wait(9_000);
|
||||
const fleet = await page.evaluate(() => {
|
||||
const fleet = await page.evaluate((feedId) => {
|
||||
const layer =
|
||||
window.__godsEyeView.dataManager.layers.get('transit').module;
|
||||
return {
|
||||
count: layer._transitStateForTest()._vehicles.size,
|
||||
count: [...layer._transitStateForTest()._vehicles.keys()].filter(
|
||||
(key) => key.startsWith(`${feedId}:`),
|
||||
).length,
|
||||
stats: layer.getStats(),
|
||||
};
|
||||
});
|
||||
}, city.feedId);
|
||||
check(
|
||||
`${city.label}: vehicles are rendered`,
|
||||
fleet.count >= 20,
|
||||
@@ -426,6 +454,7 @@ try {
|
||||
.module._transitStateForTest();
|
||||
let best = null;
|
||||
for (const entry of state._vehicles.values()) {
|
||||
if (!entry.key.startsWith(`${c.feedId}:`)) continue;
|
||||
const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon);
|
||||
if (!best || d < best.d)
|
||||
best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key };
|
||||
@@ -1607,6 +1636,7 @@ try {
|
||||
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
|
||||
];
|
||||
const frames = [];
|
||||
const observedPolls = new Set();
|
||||
const publicationsBefore = state._cardPublications || 0;
|
||||
let headSamples = 0,
|
||||
headErrorPx = 0,
|
||||
@@ -1623,6 +1653,13 @@ try {
|
||||
const proj = scene.camera.frustum.projectionMatrix;
|
||||
const p = entry.marker.position;
|
||||
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
|
||||
if (
|
||||
entry.marker.show !== false &&
|
||||
rect &&
|
||||
Number.isFinite(entry.pollSeq)
|
||||
) {
|
||||
observedPolls.add(entry.pollSeq);
|
||||
}
|
||||
frames.push({
|
||||
t: performance.now() - t0,
|
||||
mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth,
|
||||
@@ -1668,7 +1705,15 @@ try {
|
||||
headSamples++;
|
||||
}
|
||||
}
|
||||
if (performance.now() - t0 >= durationMs) finish();
|
||||
const elapsed = performance.now() - t0;
|
||||
// Keep at least 20 seconds of motion and require an observed poll
|
||||
// transition. A fixed 20s wall-clock window can miss a 15s poll
|
||||
// when an earlier refresh is still settling.
|
||||
if (
|
||||
(elapsed >= 20_000 && observedPolls.size >= 2) ||
|
||||
elapsed >= durationMs
|
||||
)
|
||||
finish();
|
||||
};
|
||||
let remove;
|
||||
const finish = () => {
|
||||
@@ -1676,6 +1721,7 @@ try {
|
||||
remove?.();
|
||||
resolve({
|
||||
selected: true,
|
||||
elapsedMs: performance.now() - t0,
|
||||
frames,
|
||||
textPublishes:
|
||||
(state._cardPublications || 0) - publicationsBefore,
|
||||
@@ -1690,7 +1736,7 @@ try {
|
||||
});
|
||||
},
|
||||
target.key,
|
||||
20_000,
|
||||
3 * TRANSIT_POLL_MS,
|
||||
);
|
||||
}
|
||||
check(
|
||||
@@ -1711,8 +1757,9 @@ try {
|
||||
);
|
||||
check(
|
||||
'card text publication stays throttled while the anchor runs at frame rate',
|
||||
anchor.textPublishes > 0 && anchor.textPublishes <= 20_000 / 250 + 3,
|
||||
`${anchor.textPublishes} text publications in 20 s`,
|
||||
anchor.textPublishes > 0 &&
|
||||
anchor.textPublishes <= anchor.elapsedMs / 250 + 3,
|
||||
`${anchor.textPublishes} text publications in ${(anchor.elapsedMs / 1000).toFixed(1)} s`,
|
||||
);
|
||||
} else {
|
||||
check(
|
||||
|
||||
@@ -124,6 +124,19 @@ export function resolveOpenSkyAuthMode({
|
||||
return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT;
|
||||
}
|
||||
|
||||
/** Whether OPENSKY_CREDENTIALS_FILE is set, with the same precedence as the auth mode. The server reads that file when the client id and secret are empty. */
|
||||
export function resolveOpenSkyCredentialsFile({
|
||||
authoritativeEnvironment = false,
|
||||
environment = process.env,
|
||||
rootDir = ROOT,
|
||||
} = {}) {
|
||||
const environmentDefines = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_CREDENTIALS_FILE');
|
||||
const value = authoritativeEnvironment && environmentDefines
|
||||
? String(environment.OPENSKY_CREDENTIALS_FILE || '')
|
||||
: String(environment.OPENSKY_CREDENTIALS_FILE || '') || readDoctorDotenvValue('OPENSKY_CREDENTIALS_FILE', rootDir);
|
||||
return isConfiguredValue(value.trim());
|
||||
}
|
||||
|
||||
function hasKeychainItem(service, account) {
|
||||
if (process.platform !== 'darwin') return false;
|
||||
const result = spawnSync('security', [
|
||||
@@ -153,10 +166,11 @@ export function resolveCredential(spec, {
|
||||
|
||||
export function buildCapabilitySummary(
|
||||
credentials,
|
||||
{ openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT } = {},
|
||||
{ openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT, openSkyCredentialsFile = false } = {},
|
||||
) {
|
||||
const configured = (name) => credentials[name]?.configured === true;
|
||||
const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET');
|
||||
const hasClientPair = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET');
|
||||
const hasOAuthCredentials = hasClientPair || openSkyCredentialsFile === true;
|
||||
const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase();
|
||||
const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode)
|
||||
? requestedOpenSkyMode
|
||||
@@ -164,11 +178,13 @@ export function buildCapabilitySummary(
|
||||
const flights = openSkyMode === 'anon'
|
||||
? 'OpenSky keyless anonymous access (rate-limited)'
|
||||
: openSkyMode === 'basic'
|
||||
? 'OpenSky Basic mode selected (credential presence and validity not verified)'
|
||||
? 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)'
|
||||
: openSkyMode === 'auto'
|
||||
? 'OpenSky auto mode selected (runtime credential choice and validity not verified)'
|
||||
: hasOAuthCredentials
|
||||
: hasClientPair
|
||||
? 'OpenSky OAuth credentials present (runtime mode and validity not verified)'
|
||||
: hasOAuthCredentials
|
||||
? 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)'
|
||||
: 'OpenSky keyless anonymous access (rate-limited)';
|
||||
const route = selectMapStartupRoute({
|
||||
googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '',
|
||||
@@ -215,6 +231,7 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment
|
||||
credentials,
|
||||
capabilities: buildCapabilitySummary(credentials, {
|
||||
openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }),
|
||||
openSkyCredentialsFile: resolveOpenSkyCredentialsFile({ authoritativeEnvironment, rootDir }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
+161
-42
@@ -1,56 +1,175 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { decodeFlowTile } from '../src/layers/traffic/flowDecode.js';
|
||||
import { tilesForBounds } from '../src/data/tomtomTiles.js';
|
||||
import { PbfWriter } from 'pbf';
|
||||
|
||||
const tile = readFileSync(
|
||||
new URL(
|
||||
'../src/data/fixtures/tomtom-flow-austin-12-935-1686.pbf',
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const corsHeaders = { 'access-control-allow-origin': '*' };
|
||||
const json = (body) => ({
|
||||
status: 200,
|
||||
headers: corsHeaders,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
/** Reuse the recorded flow tile for an explicitly synthetic browser exercise. */
|
||||
// Cesium heightmap-1.0: 65x65 little-endian uint16 heights, scale .2,
|
||||
// offset -1000, followed by child availability and a uniform land mask.
|
||||
const flatTerrain = Buffer.alloc(65 * 65 * 2 + 2);
|
||||
for (let i = 0; i < 65 * 65; i++) flatTerrain.writeUInt16LE(5000, i * 2);
|
||||
|
||||
/** Synthetic, geographically aligned motor roads at both source zooms. */
|
||||
export function trafficFixtureTile(zoom, flow = false) {
|
||||
const writer = new PbfWriter();
|
||||
writer.writeMessage(
|
||||
3,
|
||||
(_, layer) => {
|
||||
layer.writeVarintField(15, 2);
|
||||
layer.writeStringField(1, flow ? 'Traffic flow' : 'transportation');
|
||||
layer.writeVarintField(5, 4096);
|
||||
layer.writeStringField(3, flow ? 'traffic_level' : 'class');
|
||||
layer.writeMessage(
|
||||
4,
|
||||
(_, value) => {
|
||||
if (flow) value.writeDoubleField(3, 0.6);
|
||||
else value.writeStringField(1, 'primary');
|
||||
},
|
||||
null,
|
||||
);
|
||||
if (flow) {
|
||||
layer.writeStringField(3, 'road_type');
|
||||
layer.writeMessage(
|
||||
4,
|
||||
(_, value) => value.writeStringField(1, 'Major road'),
|
||||
null,
|
||||
);
|
||||
}
|
||||
// z12 has four times the span of z14. Keep the same world spacing,
|
||||
// and cover only alternate roads with flow to exercise hide/sim controls.
|
||||
const step = 256 * 2 ** (zoom - 14);
|
||||
for (let y = step; y < 4096; y += step) {
|
||||
if (flow && (y / step) % 2) continue;
|
||||
layer.writeMessage(
|
||||
2,
|
||||
(_, feature) => {
|
||||
feature.writeVarintField(3, 2);
|
||||
feature.writePackedVarint(2, flow ? [0, 0, 1, 1] : [0, 0]);
|
||||
feature.writePackedVarint(4, [9, 0, y * 2, 10, 8192, 0]);
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
},
|
||||
null,
|
||||
);
|
||||
return Buffer.from(writer.finish());
|
||||
}
|
||||
|
||||
/** Intercept the current vector sources; never fall through to live roads. */
|
||||
export function trafficFixtureResponse(request) {
|
||||
const url = new URL(request.url());
|
||||
if (url.hostname === 'terrain.reearth.land') {
|
||||
if (url.pathname.endsWith('/layer.json'))
|
||||
return json({
|
||||
tilejson: '2.1.0',
|
||||
format: 'heightmap-1.0',
|
||||
version: 'qa-flat',
|
||||
scheme: 'tms',
|
||||
projection: 'EPSG:4326',
|
||||
minzoom: 0,
|
||||
maxzoom: 0,
|
||||
tiles: ['{z}/{x}/{y}.terrain?v={version}'],
|
||||
available: [[{ startX: 0, startY: 0, endX: 1, endY: 0 }]],
|
||||
attribution:
|
||||
'Synthetic flat terrain for Traffic QA; not live elevation data',
|
||||
});
|
||||
if (/\/0\/[01]\/0\.terrain$/.test(url.pathname))
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/octet-stream',
|
||||
headers: corsHeaders,
|
||||
body: flatTerrain,
|
||||
};
|
||||
throw new Error(`Unexpected terrain fixture request: ${url.pathname}`);
|
||||
}
|
||||
if (url.pathname === '/api/tomtom/status')
|
||||
return json({ hasKey: true, dailyCount: 0 });
|
||||
if (/^\/api\/tomtom\/flow\/\d+\/\d+\/\d+\.pbf$/.test(url.pathname))
|
||||
return { status: 200, contentType: 'application/x-protobuf', body: tile };
|
||||
if (url.pathname !== '/api/overpass') return null;
|
||||
const query = new URLSearchParams(request.postData()).get('data') || '';
|
||||
if (!query.includes('highway')) return null;
|
||||
const match = query.match(/\((-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\)/);
|
||||
if (!match) throw new Error('Missing road fixture viewport');
|
||||
const [south, west, north, east] = match.slice(1).map(Number);
|
||||
const inside = ([lon, lat]) =>
|
||||
lat >= south && lat <= north && lon >= west && lon <= east;
|
||||
const segments = tilesForBounds({ south, west, north, east }, 12).flatMap(
|
||||
({ z, x, y }) => decodeFlowTile(tile, z, x, y),
|
||||
const flow = url.pathname.match(
|
||||
/^\/api\/tomtom\/flow\/(\d+)\/\d+\/\d+\.pbf$/,
|
||||
);
|
||||
const elements = segments
|
||||
.filter((segment) => segment.coords.some(inside))
|
||||
.slice(0, 240)
|
||||
.map((segment, index) => ({
|
||||
type: 'way',
|
||||
id: index + 1,
|
||||
tags: { highway: 'primary', oneway: 'yes' },
|
||||
geometry: segment.coords.map(([lon, lat]) => ({ lon, lat })),
|
||||
}));
|
||||
const latitude = (south + north) / 2,
|
||||
longitude = (west + east) / 2;
|
||||
elements.push({
|
||||
type: 'way',
|
||||
id: 99999,
|
||||
tags: { highway: 'residential' },
|
||||
geometry: [
|
||||
{ lat: latitude, lon: longitude },
|
||||
{ lat: latitude + 0.003, lon: longitude + 0.003 },
|
||||
],
|
||||
if (flow)
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/x-protobuf',
|
||||
headers: corsHeaders,
|
||||
body: trafficFixtureTile(Number(flow[1]), true),
|
||||
};
|
||||
if (url.hostname !== 'tiles.openfreemap.org') return null;
|
||||
if (url.pathname === '/planet')
|
||||
return json({
|
||||
tilejson: '3.0.0',
|
||||
minzoom: 0,
|
||||
maxzoom: 14,
|
||||
tiles: ['https://tiles.openfreemap.org/qa-traffic/{z}/{x}/{y}.pbf'],
|
||||
});
|
||||
const road = url.pathname.match(/^\/qa-traffic\/(\d+)\/\d+\/\d+\.pbf$/);
|
||||
if (!road)
|
||||
throw new Error(`Unrecognized road fixture request: ${url.pathname}`);
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/x-protobuf',
|
||||
headers: corsHeaders,
|
||||
body: trafficFixtureTile(Number(road[1])),
|
||||
};
|
||||
}
|
||||
|
||||
/** Intercept provider URLs only; Cesium worker/module loading stays untouched. */
|
||||
export async function installTrafficRoutes(
|
||||
page,
|
||||
{
|
||||
origin,
|
||||
fixtures = false,
|
||||
keyless = false,
|
||||
onFlow = () => {},
|
||||
onError = () => {},
|
||||
},
|
||||
) {
|
||||
const session = await page.createCDPSession();
|
||||
const patterns = [
|
||||
`${origin}/api/tomtom/status*`,
|
||||
`${origin}/api/tomtom/flow/*`,
|
||||
...(fixtures
|
||||
? ['https://tiles.openfreemap.org/*', 'https://terrain.reearth.land/*']
|
||||
: []),
|
||||
].map((urlPattern) => ({ urlPattern, requestStage: 'Request' }));
|
||||
session.on('Fetch.requestPaused', async ({ requestId, request }) => {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
let response;
|
||||
if (keyless && url.pathname === '/api/tomtom/status') {
|
||||
response = json({ hasKey: false });
|
||||
} else if (keyless && url.pathname.startsWith('/api/tomtom/flow/')) {
|
||||
onFlow(request.url);
|
||||
response = { ...json({ error: 'no_key' }), status: 503 };
|
||||
} else if (fixtures) {
|
||||
response = trafficFixtureResponse({ url: () => request.url });
|
||||
}
|
||||
if (!response) {
|
||||
await session.send('Fetch.continueRequest', { requestId });
|
||||
return;
|
||||
}
|
||||
await session.send('Fetch.fulfillRequest', {
|
||||
requestId,
|
||||
responseCode: response.status,
|
||||
responseHeaders: Object.entries({
|
||||
'content-type': response.contentType,
|
||||
...response.headers,
|
||||
}).map(([name, value]) => ({ name, value })),
|
||||
body: Buffer.from(response.body).toString('base64'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (page.isClosed()) return;
|
||||
onError(error);
|
||||
await session
|
||||
.send('Fetch.failRequest', { requestId, errorReason: 'Failed' })
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
return json({ elements });
|
||||
await session.send('Fetch.enable', { patterns });
|
||||
return session;
|
||||
}
|
||||
@@ -116,6 +116,9 @@ export const CORE_PHRASES = Object.freeze([
|
||||
// — camera verbs (tools #23/#24) —
|
||||
{ phrase: 'Orbit around this area slowly', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'orbit' && a.speed === 'slow')] },
|
||||
{ phrase: 'Pan left a bit', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'pan' && a.direction === 'left')] },
|
||||
{ phrase: 'Rotate the globe to the right', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate_globe' && (a.direction === undefined || a.direction === 'right'))] },
|
||||
{ phrase: 'Keep spinning the Earth slowly to the left', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate_globe' && a.direction === 'left' && a.speed === 'slow' && (a.mode === undefined || a.mode === 'continuous'))] },
|
||||
{ phrase: 'Turn the camera to look right', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate' && a.direction === 'right')] },
|
||||
{ phrase: 'Stop moving the camera', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'stop')] },
|
||||
{ phrase: 'Fly the route we just drew', expect: 'fly_route' },
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js';
|
||||
import {
|
||||
coalesceProxyRequest,
|
||||
@@ -137,6 +139,10 @@ function openskyAdaptiveTtlMs(remaining) {
|
||||
}
|
||||
/** @type {boolean} Guards duplicate auth-failure warnings in logs. */
|
||||
let _openskyAuthWarned = false;
|
||||
/** One warning per process when OPENSKY_CREDENTIALS_FILE cannot be read. */
|
||||
let _openskyCredentialsFileWarned = false;
|
||||
/** One warning per process when OPENSKY_AUTH_MODE=basic is requested. */
|
||||
let _openskyBasicRetiredWarned = false;
|
||||
/** @type {boolean} Guards duplicate invalid-auth-mode warnings. */
|
||||
let _openskyAuthModeWarned = false;
|
||||
/** Default auth mode when OPENSKY_AUTH_MODE env is unset. */
|
||||
@@ -156,6 +162,48 @@ const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||
// current instead of coasting a stale worldwide frame indefinitely.
|
||||
const OPENSKY_SOURCE_STALE_MS = 120_000;
|
||||
|
||||
/**
|
||||
* The OpenSky API client for OAuth: OPENSKY_CLIENT_ID and
|
||||
* OPENSKY_CLIENT_SECRET, or else the credentials.json that OpenSky's account
|
||||
* page downloads, named by OPENSKY_CREDENTIALS_FILE ("~" is the home
|
||||
* directory). The file is read on each token refresh, so a replaced file is
|
||||
* picked up without a restart.
|
||||
*
|
||||
* @param {Record<string, string|undefined>} [env]
|
||||
* @param {(path: string) => string} [readFile]
|
||||
* @returns {{ clientId: string, clientSecret: string } | null}
|
||||
*/
|
||||
export function openSkyClientCredentials(
|
||||
env = process.env,
|
||||
readFile = (path) => fs.readFileSync(path, 'utf8'),
|
||||
) {
|
||||
const clientId = String(env.OPENSKY_CLIENT_ID || '').trim();
|
||||
const clientSecret = String(env.OPENSKY_CLIENT_SECRET || '').trim();
|
||||
if (clientId && clientSecret) return { clientId, clientSecret };
|
||||
const file = String(env.OPENSKY_CREDENTIALS_FILE || '').trim();
|
||||
if (!file) return null;
|
||||
const path = file.replace(/^~(?=$|[\\/])/, os.homedir());
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFile(path));
|
||||
} catch {
|
||||
if (!_openskyCredentialsFileWarned) {
|
||||
console.warn(
|
||||
'[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.',
|
||||
);
|
||||
_openskyCredentialsFileWarned = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const fileId = String(raw?.clientId ?? raw?.client_id ?? '').trim();
|
||||
const fileSecret = String(
|
||||
raw?.clientSecret ?? raw?.client_secret ?? '',
|
||||
).trim();
|
||||
return fileId && fileSecret
|
||||
? { clientId: fileId, clientSecret: fileSecret }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain a valid OpenSky OAuth2 bearer token, refreshing if needed.
|
||||
*
|
||||
@@ -174,9 +222,9 @@ export async function getOpenSkyToken() {
|
||||
// return the same promise instead of issuing a duplicate token request
|
||||
if (_openskyTokenPromise) return _openskyTokenPromise;
|
||||
|
||||
const clientId = process.env.OPENSKY_CLIENT_ID;
|
||||
const clientSecret = process.env.OPENSKY_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) return null;
|
||||
const credentials = openSkyClientCredentials();
|
||||
if (!credentials) return null;
|
||||
const { clientId, clientSecret } = credentials;
|
||||
|
||||
// Wrap the async token fetch in a shared promise stored in _openskyTokenPromise
|
||||
_openskyTokenPromise = (async () => {
|
||||
@@ -417,8 +465,10 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
|
||||
*
|
||||
* Supports four auth modes controlled by OPENSKY_AUTH_MODE env:
|
||||
* - 'oauth' (default) — client_credentials bearer token
|
||||
* - 'basic' — HTTP Basic with OPENSKY_USERNAME / OPENSKY_PASSWORD
|
||||
* - 'auto' — try OAuth first, fall back to Basic, then anon
|
||||
* - 'basic' — retired: OpenSky stopped accepting HTTP Basic (username and
|
||||
* password) on 2026-03-18, so this requests anonymously and
|
||||
* warns once
|
||||
* - 'auto' — try OAuth first, then anon
|
||||
* - 'anon' — no credentials
|
||||
*
|
||||
* Successful responses are cached for OPENSKY_CACHE_MS (~9 s). On
|
||||
@@ -524,21 +574,21 @@ export function openSkyProxy() {
|
||||
_openskySnapshotInFlight,
|
||||
'global',
|
||||
async () => {
|
||||
const basicUser = process.env.OPENSKY_USERNAME || '';
|
||||
const basicPass = process.env.OPENSKY_PASSWORD || '';
|
||||
const hasBasicCreds = Boolean(basicUser && basicPass);
|
||||
const headers = { Accept: 'application/json' };
|
||||
let usedMode = 'anon';
|
||||
let reason = 'forced_anonymous';
|
||||
|
||||
if (requestedMode === 'basic') {
|
||||
if (hasBasicCreds) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
|
||||
usedMode = 'basic';
|
||||
reason = 'basic_credentials';
|
||||
} else {
|
||||
reason = 'missing_basic_creds';
|
||||
// OpenSky no longer accepts a Basic header, so sending
|
||||
// OPENSKY_USERNAME / OPENSKY_PASSWORD would only fail; ask
|
||||
// anonymously and say once what to configure instead.
|
||||
if (!_openskyBasicRetiredWarned) {
|
||||
console.warn(
|
||||
'[OpenSky] OPENSKY_AUTH_MODE=basic: OpenSky retired username/password sign-in on 2026-03-18, so flights load anonymously (rate-limited). Use OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.',
|
||||
);
|
||||
_openskyBasicRetiredWarned = true;
|
||||
}
|
||||
reason = 'basic_retired';
|
||||
} else if (requestedMode === 'oauth') {
|
||||
const token = await getOpenSkyToken();
|
||||
if (token) {
|
||||
@@ -554,31 +604,12 @@ export function openSkyProxy() {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
usedMode = 'oauth';
|
||||
reason = 'oauth_token';
|
||||
} else if (hasBasicCreds) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
|
||||
usedMode = 'basic';
|
||||
reason = 'oauth_unavailable_fallback_basic';
|
||||
} else {
|
||||
reason = 'missing_oauth_and_basic_creds';
|
||||
reason = 'missing_oauth_creds';
|
||||
}
|
||||
}
|
||||
|
||||
let { upstream, body } = await fetchOpenSkyStates(headers);
|
||||
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
|
||||
if (
|
||||
(upstream.status === 401 || upstream.status === 403) &&
|
||||
requestedMode === 'auto' &&
|
||||
usedMode === 'oauth' &&
|
||||
hasBasicCreds
|
||||
) {
|
||||
const retryHeaders = {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
|
||||
};
|
||||
({ upstream, body } = await fetchOpenSkyStates(retryHeaders));
|
||||
usedMode = 'basic';
|
||||
reason = 'oauth_rejected_fallback_basic';
|
||||
}
|
||||
const { upstream, body } = await fetchOpenSkyStates(headers);
|
||||
|
||||
const sourceEpochMs = upstream.ok
|
||||
? openSkySourceEpochMs(body)
|
||||
@@ -586,12 +617,6 @@ export function openSkyProxy() {
|
||||
// Refine the reason string to reflect the actual outcome
|
||||
if (upstream.ok && reason === 'forced_anonymous') {
|
||||
reason = 'anonymous_ok';
|
||||
} else if (
|
||||
upstream.ok &&
|
||||
usedMode === 'basic' &&
|
||||
reason === 'basic_credentials'
|
||||
) {
|
||||
reason = 'basic_ok';
|
||||
} else if (
|
||||
upstream.ok &&
|
||||
usedMode === 'oauth' &&
|
||||
@@ -627,12 +652,11 @@ export function openSkyProxy() {
|
||||
body,
|
||||
usedMode,
|
||||
reason,
|
||||
hasBasicCreds,
|
||||
sourceEpochMs,
|
||||
};
|
||||
},
|
||||
);
|
||||
let { upstream, body, usedMode, reason, hasBasicCreds, sourceEpochMs } =
|
||||
let { upstream, body, usedMode, reason, sourceEpochMs } =
|
||||
await refresh.promise;
|
||||
if (
|
||||
upstream.ok &&
|
||||
@@ -684,35 +708,24 @@ export function openSkyProxy() {
|
||||
}
|
||||
|
||||
if (upstream.status === 401 || upstream.status === 403) {
|
||||
if (requestedMode === 'basic' && !hasBasicCreds) {
|
||||
if (requestedMode === 'oauth' && usedMode !== 'oauth') {
|
||||
body = JSON.stringify({
|
||||
error:
|
||||
'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.',
|
||||
});
|
||||
reason = 'missing_basic_creds';
|
||||
} else if (requestedMode === 'oauth' && usedMode !== 'oauth') {
|
||||
body = JSON.stringify({
|
||||
error:
|
||||
'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.',
|
||||
'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.',
|
||||
});
|
||||
reason = 'oauth_invalid_or_missing';
|
||||
} else if (usedMode === 'basic') {
|
||||
body = JSON.stringify({
|
||||
error: 'OpenSky auth invalid. Username/password were rejected.',
|
||||
});
|
||||
reason = 'basic_invalid_credentials';
|
||||
} else if (usedMode === 'oauth') {
|
||||
body = JSON.stringify({
|
||||
error:
|
||||
'OpenSky auth invalid. OAuth client credentials were rejected.',
|
||||
});
|
||||
reason = 'oauth_invalid_credentials';
|
||||
} else if (requestedMode === 'auto' && !hasBasicCreds) {
|
||||
} else if (requestedMode === 'auto') {
|
||||
body = JSON.stringify({
|
||||
error:
|
||||
'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.',
|
||||
'OpenSky auth missing. Provide OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.',
|
||||
});
|
||||
reason = 'missing_oauth_and_basic_creds';
|
||||
reason = 'missing_oauth_creds';
|
||||
} else {
|
||||
body = JSON.stringify({
|
||||
error: 'OpenSky auth required.',
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { openSkyClientCredentials } from './opensky.js';
|
||||
|
||||
const file = (contents) => (filePath) => {
|
||||
file.read = filePath;
|
||||
if (contents instanceof Error) throw contents;
|
||||
return contents;
|
||||
};
|
||||
|
||||
test('OpenSky client env vars come first', () => {
|
||||
const read = () => assert.fail('the file is not read when env vars are set');
|
||||
assert.deepEqual(
|
||||
openSkyClientCredentials(
|
||||
{
|
||||
OPENSKY_CLIENT_ID: ' id ',
|
||||
OPENSKY_CLIENT_SECRET: 'secret',
|
||||
OPENSKY_CREDENTIALS_FILE: 'credentials.json',
|
||||
},
|
||||
read,
|
||||
),
|
||||
{ clientId: 'id', clientSecret: 'secret' },
|
||||
);
|
||||
});
|
||||
|
||||
test('OPENSKY_CREDENTIALS_FILE supplies the client when env vars are empty', () => {
|
||||
const env = {
|
||||
OPENSKY_CLIENT_ID: '',
|
||||
OPENSKY_CREDENTIALS_FILE: '~/Downloads/credentials.json',
|
||||
};
|
||||
// The file OpenSky's account page downloads uses camelCase keys.
|
||||
assert.deepEqual(
|
||||
openSkyClientCredentials(
|
||||
env,
|
||||
file('{"clientId":"cid","clientSecret":"csecret"}'),
|
||||
),
|
||||
{ clientId: 'cid', clientSecret: 'csecret' },
|
||||
);
|
||||
assert.equal(
|
||||
path.normalize(file.read),
|
||||
path.normalize(path.join(os.homedir(), 'Downloads/credentials.json')),
|
||||
);
|
||||
assert.deepEqual(
|
||||
openSkyClientCredentials(
|
||||
env,
|
||||
file('{"client_id":"cid","client_secret":"csecret"}'),
|
||||
),
|
||||
{ clientId: 'cid', clientSecret: 'csecret' },
|
||||
);
|
||||
});
|
||||
|
||||
test('a missing, unreadable or incomplete credentials file means no client', () => {
|
||||
assert.equal(openSkyClientCredentials({}, file('{}')), null);
|
||||
const env = { OPENSKY_CREDENTIALS_FILE: 'credentials.json' };
|
||||
const missing = Object.assign(new Error('nope'), { code: 'ENOENT' });
|
||||
assert.equal(openSkyClientCredentials(env, file(missing)), null);
|
||||
assert.equal(openSkyClientCredentials(env, file('not json')), null);
|
||||
assert.equal(openSkyClientCredentials(env, file('{"clientId":"cid"}')), null);
|
||||
});
|
||||
|
||||
test('credential file warnings never expose file contents or read errors', async (t) => {
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (message) => warnings.push(message));
|
||||
for (const contents of [
|
||||
'{"clientId":"id","clientSecret":FAKE_SECRET_123}',
|
||||
new Error('could not read FAKE_SECRET_123'),
|
||||
]) {
|
||||
const { openSkyClientCredentials: resolve } = await import(
|
||||
`./opensky.js?warning=${warnings.length}`
|
||||
);
|
||||
const env = { OPENSKY_CREDENTIALS_FILE: '/synthetic/credentials.json' };
|
||||
assert.equal(resolve(env, file(contents)), null);
|
||||
assert.equal(resolve(env, file(contents)), null);
|
||||
}
|
||||
assert.deepEqual(warnings, [
|
||||
'[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.',
|
||||
'[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.',
|
||||
]);
|
||||
});
|
||||
@@ -7,10 +7,9 @@ import { readCappedResponseText } from '../common/http.js';
|
||||
* own credit bucket, 4 credits per call on the free tier). OAuth via the
|
||||
* shared coalesced token. 60s per-icao cache; 404/429 forwarded so the
|
||||
* client can fall back to its accumulated trail silently.
|
||||
* /api/military/track?hex=<hex> — adsb.lol tar1090 readsb trace
|
||||
* (undocumented but live; no browser CORS, hence this proxy). Up to ~24h
|
||||
* of real history per aircraft. Treat as best-effort; data is ODbL —
|
||||
* credit "adsb.lol (ODbL)" in the UI.
|
||||
* /api/military/track?hex=<hex> — compatibility refusal for the retired,
|
||||
* undocumented ADSB.lol trace route. The standalone military source uses
|
||||
* locally accumulated trails; provider-supplied history remains optional.
|
||||
*/
|
||||
export function trackBackfillProxies() {
|
||||
const TRACK_CACHE_MS = 60000;
|
||||
@@ -107,10 +106,16 @@ export function trackBackfillProxies() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
await proxyJson(
|
||||
res,
|
||||
`lol:${hex}`,
|
||||
`https://adsb.lol/data/traces/${hex.slice(-2)}/trace_full_${hex}.json`,
|
||||
res.statusCode = 503;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('Retry-After', '1800');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error:
|
||||
'Military history unavailable; locally accumulated trails remain available',
|
||||
code: 'history_unavailable',
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
res.statusCode = 502;
|
||||
|
||||
@@ -43,6 +43,11 @@ function number(value, min, max) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n >= min && n <= max ? n : null;
|
||||
}
|
||||
// CurrentStorms.json reports movementSpeed in miles per hour (intensity is in
|
||||
// knots); convert so `speedKt` means what it says.
|
||||
function knotsFromMph(mph) {
|
||||
return mph === null ? null : Math.round(mph * 0.868976);
|
||||
}
|
||||
// NHC stamps an advisory with its nominal time (e.g. 03:00Z) and publishes it
|
||||
// up to about half an hour before; allow that, but not a clock far ahead.
|
||||
const EARLY_ADVISORY_MS = 90 * 60_000;
|
||||
@@ -120,7 +125,7 @@ export function parseCycloneStatus(payload, now = Date.now()) {
|
||||
pressureHpa: number(raw.pressure, 800, 1100),
|
||||
movement: {
|
||||
directionDegrees: number(raw.movementDir, 0, 360),
|
||||
speedKt: number(raw.movementSpeed, 0, 200),
|
||||
speedKt: knotsFromMph(number(raw.movementSpeed, 0, 200)),
|
||||
},
|
||||
advisoryUrl: officialLink(forecast.url),
|
||||
outlookUrl: raw.id.startsWith('al')
|
||||
|
||||
@@ -16,6 +16,7 @@ function realtimeInstructions(
|
||||
'For "what is this aircraft?", answer with get_entity_context\'s identityLine: callsign, operator, type and route from returned fields only, airport codes verbatim. Other questions about the selection answer from its properties. Never infer them from the callsign.',
|
||||
'While a camera motion or route flight is active, a bare "stop" means move_camera{motion:stop} — NOT control_scene and NOT stop_tracking (those need explicit words like "stop the scene" / "stop tracking"). If move_camera stop returns stopped:false and an entity is being tracked, call stop_tracking next — the user means "stop whatever is moving". Flying somewhere while tracking stops the tracking.',
|
||||
'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").',
|
||||
'Globe spin: plain Rotate, including a repeat after a refusal, calls only move_camera motion rotate_globe; the app checks fit. For rotation, call zoom_to_globe only for this turn\'s explicit user Reset/Show Globe View request, never from your own plan, an earlier refusal or a bare yes/ok. On globe-view-required say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" Do not ask a question or call more tools for that request. Explicit reset-and-rotate: await reset success before rotate_globe; never both together. After a failed reset, do not retry for that request. Local turn/look left/right: move_camera motion rotate in that direction, not pan.',
|
||||
'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.',
|
||||
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.',
|
||||
'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.',
|
||||
|
||||
@@ -511,18 +511,18 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
move_camera: {
|
||||
description:
|
||||
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").',
|
||||
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.',
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
direction: {
|
||||
description:
|
||||
'Required except for orbit (defaults right/clockwise) and stop.',
|
||||
'Required for pan/tilt/rotate; orbit/rotate_globe default right.',
|
||||
$position: 2,
|
||||
},
|
||||
mode: {
|
||||
description:
|
||||
'once = bounded eased nudge (default); continuous = until stop/manual input.',
|
||||
'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.',
|
||||
$position: 2,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -164,6 +164,30 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
readDisk: () =>
|
||||
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
||||
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
|
||||
loadUpstream: async () => {
|
||||
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT)
|
||||
return {
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
retryAfterMs: 2000,
|
||||
body: JSON.stringify({
|
||||
error: 'Overpass proxy busy — try again shortly',
|
||||
}),
|
||||
};
|
||||
_overpassConcurrent++;
|
||||
try {
|
||||
const payload = await fetchOverpassPayload(safeBody);
|
||||
if (overpassPayloadIsData(payload)) {
|
||||
const entry = { ...payload, cachedAt: Date.now() };
|
||||
_overpassCache.set(cacheKey, entry);
|
||||
trimOverpassCache();
|
||||
writeOverpassDisk(cacheKey, entry);
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
_overpassConcurrent--;
|
||||
}
|
||||
},
|
||||
});
|
||||
if (preflight.source === 'RATE_LIMITED') {
|
||||
res.writeHead(429, {
|
||||
@@ -173,71 +197,20 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
if (preflight.source !== 'UPSTREAM') {
|
||||
// A coalesced caller sees the same failure as the original request
|
||||
// and must get the same last-good fallback, not the raw refusal.
|
||||
if (!overpassPayloadIsData(preflight.payload)) {
|
||||
const stale = await readStaleOverpass(cacheKey);
|
||||
if (stale) {
|
||||
sendOverpassResponse(res, stale, 'STALE');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (preflight.source === 'DISK') {
|
||||
_overpassCache.set(cacheKey, preflight.payload);
|
||||
trimOverpassCache();
|
||||
}
|
||||
sendOverpassResponse(res, preflight.payload, preflight.source);
|
||||
return;
|
||||
}
|
||||
|
||||
// From here onward the request is genuinely upstream-bound and has
|
||||
// consumed one local limiter slot. Cache and dedupe hits above do not.
|
||||
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) {
|
||||
res.writeHead(503, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '2',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'Overpass proxy busy — try again shortly',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
_overpassConcurrent += 1;
|
||||
const requestPromise = fetchOverpassPayload(safeBody)
|
||||
.then((payload) => {
|
||||
// Only a 2xx is data. `< 500` cached every 4xx, so one mirror's
|
||||
// refusal was written to memory AND disk — and boundary-class
|
||||
// queries hold a month-long TTL, so a single 406 outlived the
|
||||
// outage that caused it.
|
||||
if (overpassPayloadIsData(payload)) {
|
||||
const entry = { ...payload, cachedAt: Date.now() };
|
||||
_overpassCache.set(cacheKey, entry);
|
||||
trimOverpassCache();
|
||||
writeOverpassDisk(cacheKey, entry);
|
||||
}
|
||||
return payload;
|
||||
})
|
||||
.finally(() => {
|
||||
_overpassConcurrent -= 1;
|
||||
_overpassInFlight.delete(cacheKey);
|
||||
});
|
||||
|
||||
_overpassInFlight.set(cacheKey, requestPromise);
|
||||
const payload = await requestPromise;
|
||||
// Degraded upstream (rate-limited on every mirror / 5xx / runtime
|
||||
// error): last-good roads beat an empty layer — serve stale from
|
||||
// memory or disk at ANY age before surfacing the failure.
|
||||
if (!overpassPayloadIsData(payload)) {
|
||||
if (!overpassPayloadIsData(preflight.payload)) {
|
||||
const stale = await readStaleOverpass(cacheKey);
|
||||
if (stale) {
|
||||
sendOverpassResponse(res, stale, 'STALE');
|
||||
return;
|
||||
}
|
||||
}
|
||||
sendOverpassResponse(res, payload, 'MISS');
|
||||
// Preflight already published the disk hit to memory; only trim.
|
||||
if (preflight.source === 'DISK') trimOverpassCache();
|
||||
sendOverpassResponse(
|
||||
res,
|
||||
preflight.payload,
|
||||
preflight.source === 'UPSTREAM' ? 'MISS' : preflight.source,
|
||||
);
|
||||
} catch (e) {
|
||||
// Every mirror threw (network-level). Same serve-stale rule.
|
||||
const stale = cacheKey ? await readStaleOverpass(cacheKey) : null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { coalesceProxyRequest } from '../common/http.js';
|
||||
import { isOverpassBoundaryQuery } from './query.js';
|
||||
import {
|
||||
OVERPASS_BOUNDARY_DISK_TTL_MS,
|
||||
@@ -78,7 +79,9 @@ function writeOverpassDisk(cacheKey, payload) {
|
||||
/**
|
||||
* Resolve every cache/coalescing layer before admitting a request to the local
|
||||
* upstream rate limiter. The injected limiter callback is invoked exactly once
|
||||
* for a complete cache miss and never for memory, in-flight, or disk hits.
|
||||
* for a complete cache miss and never for memory, in-flight, or disk hits. A
|
||||
* caller that joined a reservation rejected by another client's limiter
|
||||
* retries with its own limiter instead of inheriting that 429.
|
||||
* Exported so the admission ordering can be tested without a Vite server.
|
||||
*
|
||||
* @param {object} options
|
||||
@@ -87,6 +90,7 @@ function writeOverpassDisk(cacheKey, payload) {
|
||||
* @param {Map<string, Promise<object>>} options.inFlight
|
||||
* @param {()=>Promise<object|null>} options.readDisk
|
||||
* @param {()=>boolean} options.allowUpstream
|
||||
* @param {()=>Promise<object>} options.loadUpstream Fetch and publish the admitted result.
|
||||
* @param {number} [options.now]
|
||||
* @param {number} [options.cacheMs]
|
||||
* @returns {Promise<{source:'HIT'|'INFLIGHT'|'DISK'|'UPSTREAM'|'RATE_LIMITED', payload:object|null}>}
|
||||
@@ -97,22 +101,34 @@ async function resolveOverpassPreflight({
|
||||
inFlight,
|
||||
readDisk,
|
||||
allowUpstream,
|
||||
loadUpstream = async () => null,
|
||||
now = Date.now(),
|
||||
cacheMs = OVERPASS_CACHE_MS,
|
||||
}) {
|
||||
const cached = memoryCache.get(cacheKey);
|
||||
if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs)
|
||||
return { source: 'HIT', payload: cached };
|
||||
for (;;) {
|
||||
const cached = memoryCache.get(cacheKey);
|
||||
if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs)
|
||||
return { source: 'HIT', payload: cached };
|
||||
|
||||
const pending = inFlight.get(cacheKey);
|
||||
if (pending) return { source: 'INFLIGHT', payload: await pending };
|
||||
|
||||
const disk = await readDisk();
|
||||
if (overpassPayloadIsData(disk)) return { source: 'DISK', payload: disk };
|
||||
|
||||
return allowUpstream()
|
||||
? { source: 'UPSTREAM', payload: null }
|
||||
: { source: 'RATE_LIMITED', payload: null };
|
||||
// Reserve synchronously before disk I/O. The owner retains the reservation
|
||||
// through its own upstream admission, fetch and cache publication.
|
||||
const request = coalesceProxyRequest(inFlight, cacheKey, async () => {
|
||||
const disk = await readDisk();
|
||||
if (overpassPayloadIsData(disk)) {
|
||||
memoryCache.set(cacheKey, disk);
|
||||
return { source: 'DISK', payload: disk };
|
||||
}
|
||||
if (!allowUpstream()) return { source: 'RATE_LIMITED', payload: null };
|
||||
return { source: 'UPSTREAM', payload: await loadUpstream() };
|
||||
});
|
||||
const result = await request.promise;
|
||||
if (!request.shared) return result;
|
||||
// The owner's quota rejection belongs to the owner's client. The rejected
|
||||
// reservation is released before joiners resume, so retry and let this
|
||||
// caller own the next reservation (or join an admitted one) on its quota.
|
||||
if (result.source === 'RATE_LIMITED') continue;
|
||||
return { ...result, source: 'INFLIGHT' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Return only last-good Overpass data, regardless of its age. */
|
||||
|
||||
@@ -5,7 +5,7 @@ import { readResponseTextCapped } from '../common/http.js';
|
||||
import {
|
||||
normalizeRadioBrowserStation,
|
||||
publicRadioStation,
|
||||
cleanRadioText,
|
||||
normalizeRadioTag,
|
||||
} from './stations.js';
|
||||
import {
|
||||
radioMirrorOrigin,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
RADIO_USER_AGENT,
|
||||
RADIO_UUID_RE,
|
||||
RADIO_FALLBACK_MIRRORS,
|
||||
RADIO_REJECTED_STATION_COUNT_MAX,
|
||||
} from './constants.js';
|
||||
async function mapRadioConcurrent(values, concurrency, mapper) {
|
||||
const results = new Array(values.length);
|
||||
@@ -177,29 +178,25 @@ export function createRadioProxyMiddleware({
|
||||
const rows = await fetchPath(`/json/stations/search?${params}`);
|
||||
if (!Array.isArray(rows))
|
||||
throw new Error('Radio Browser catalog payload was not an array');
|
||||
if (
|
||||
!rows.every(
|
||||
(row) =>
|
||||
row &&
|
||||
typeof row === 'object' &&
|
||||
!Array.isArray(row) &&
|
||||
typeof row.stationuuid === 'string' &&
|
||||
typeof row.name === 'string' &&
|
||||
(typeof row.url_resolved === 'string' ||
|
||||
typeof row.url === 'string'),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
'Radio Browser catalog contained a malformed station row',
|
||||
);
|
||||
const stations = rows
|
||||
const structuralRows = rows.filter(
|
||||
(row) =>
|
||||
row &&
|
||||
typeof row === 'object' &&
|
||||
!Array.isArray(row) &&
|
||||
typeof row.stationuuid === 'string' &&
|
||||
typeof row.name === 'string' &&
|
||||
(typeof row.url_resolved === 'string' ||
|
||||
typeof row.url === 'string'),
|
||||
);
|
||||
// Malformed rows are skipped individually, but counted so the
|
||||
// catalog keeps a diagnostic for upstream schema drift. Policy
|
||||
// rejections in normalization (offline, non-HTTPS, codec) are normal
|
||||
// filtering and are not counted.
|
||||
const rejectedRows = rows.length - structuralRows.length;
|
||||
const stations = structuralRows
|
||||
.map(normalizeRadioBrowserStation)
|
||||
.filter(Boolean);
|
||||
const requestedTag = cleanRadioText(tag, 80)
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const requestedTag = normalizeRadioTag(tag);
|
||||
const requestedTagCovered =
|
||||
!requestedTag ||
|
||||
stations.some((station) =>
|
||||
@@ -215,9 +212,10 @@ export function createRadioProxyMiddleware({
|
||||
// also contain an accepted station tagged for the requested category.
|
||||
succeeded: stations.length > 0 && requestedTagCovered,
|
||||
stations,
|
||||
rejectedRows,
|
||||
};
|
||||
} catch {
|
||||
return { succeeded: false, stations: [] };
|
||||
return { succeeded: false, stations: [], rejectedRows: 0 };
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -257,6 +255,10 @@ export function createRadioProxyMiddleware({
|
||||
if (selected.length < RADIO_CATALOG_HEALTHY_MIN_STATIONS)
|
||||
healthReasons.push('station-coverage-below-policy');
|
||||
const degraded = healthReasons.length > 0;
|
||||
const rejectedStationCount = Math.min(
|
||||
RADIO_REJECTED_STATION_COUNT_MAX,
|
||||
outcomes.reduce((total, outcome) => total + outcome.rejectedRows, 0),
|
||||
);
|
||||
const coverage = {
|
||||
successfulQueries,
|
||||
totalQueries: queries.length,
|
||||
@@ -271,6 +273,9 @@ export function createRadioProxyMiddleware({
|
||||
degraded,
|
||||
degradedReason: degraded ? healthReasons.join(',') : null,
|
||||
coverage,
|
||||
// Diagnostic only: it never fails health policy, so a usable generation
|
||||
// with a few skipped rows is still admitted. The client reports it.
|
||||
rejectedStationCount,
|
||||
};
|
||||
if (degraded && catalogCache) {
|
||||
const error = new Error(
|
||||
@@ -360,6 +365,7 @@ export function createRadioProxyMiddleware({
|
||||
coverage: catalog.coverage || null,
|
||||
acceptedGeneration: catalog.acceptedGeneration ?? null,
|
||||
catalogInstance,
|
||||
rejectedStationCount: catalog.rejectedStationCount ?? 0,
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 503, {
|
||||
|
||||
@@ -11,6 +11,9 @@ export const RADIO_CATALOG_HEALTHY_MIN_STATIONS = Math.ceil(
|
||||
export const RADIO_USER_AGENT =
|
||||
'GodsEyeView/1.0 (Radio Browser directory client)';
|
||||
export { RADIO_UUID_RE } from '../../../src/sources/radioBrowser.js';
|
||||
// Upper bound for the structurally malformed upstream rows reported in the
|
||||
// /stations envelope. The client layer validates against the same bound.
|
||||
export const RADIO_REJECTED_STATION_COUNT_MAX = 10_000;
|
||||
export const RADIO_FALLBACK_MIRRORS = Object.freeze([
|
||||
'https://de1.api.radio-browser.info',
|
||||
'https://de2.api.radio-browser.info',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
cleanRadioText,
|
||||
normalizeRadioTag,
|
||||
isNonGlobalIpv4,
|
||||
publicRadioHttpsUrl,
|
||||
normalizeRadioBrowserStation,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import path from 'node:path';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js';
|
||||
import { providerCooldownMs } from './cooldown.js';
|
||||
import { admitSameSiteKeyless } from '../common/same-site.js';
|
||||
import { readResponseTextCapped } from '../common/http.js';
|
||||
|
||||
/**
|
||||
@@ -24,6 +26,18 @@ import { readResponseTextCapped } from '../common/http.js';
|
||||
export function celestrakProxy() {
|
||||
const TLE_TTL_MS = 6 * 3600_000;
|
||||
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
|
||||
const groups = new Set([
|
||||
'stations',
|
||||
'visual',
|
||||
'gps-ops',
|
||||
'glo-ops',
|
||||
'galileo',
|
||||
'geo',
|
||||
'starlink',
|
||||
// Space Missions (`getActiveTle`) reads the full active catalog.
|
||||
'active',
|
||||
]);
|
||||
let cooldownUntil = 0;
|
||||
const mem = new Map(); // group -> { at: epochMs, body: string }
|
||||
const inflight = new Map(); // group -> Promise<{at, body}|null>
|
||||
|
||||
@@ -64,7 +78,16 @@ export function celestrakProxy() {
|
||||
'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (!res.ok) {
|
||||
// Group refreshes run in parallel: a later failure without Retry-After
|
||||
// must never shorten a longer deadline an earlier one already set.
|
||||
cooldownUntil = Math.max(
|
||||
cooldownUntil,
|
||||
Date.now() + providerCooldownMs(res),
|
||||
);
|
||||
void res.body?.cancel().catch(() => {});
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const body = await readResponseTextCapped(
|
||||
res,
|
||||
CELESTRAK_MAX_RESPONSE_BYTES,
|
||||
@@ -80,12 +103,20 @@ export function celestrakProxy() {
|
||||
const group = String(req.url || '')
|
||||
.replace(/^\//, '')
|
||||
.split('?')[0];
|
||||
if (!/^[a-z0-9-]+$/i.test(group)) {
|
||||
// Keyless and needed to render the satellites layer: refuse cross-site
|
||||
// browsers but keep the documented reverse-proxy deployment working.
|
||||
if (admitSameSiteKeyless(req, res)) return;
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (!groups.has(group)) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||
res.end('invalid group');
|
||||
return;
|
||||
}
|
||||
const send = (status, body, cacheStatus) => {
|
||||
const send = (status, body, cacheStatus, entry) => {
|
||||
// Guard against a double-send (e.g. a throw AFTER a response already
|
||||
// went out routing into the catch's send): writeHead after headersSent
|
||||
// throws "Cannot set headers after they are sent".
|
||||
@@ -93,6 +124,17 @@ export function celestrakProxy() {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'text/plain',
|
||||
'x-tle-cache': cacheStatus,
|
||||
'Cache-Control': 'no-store',
|
||||
...(entry
|
||||
? { 'X-TLE-Cached-At': new Date(entry.at).toISOString() }
|
||||
: {}),
|
||||
...(cooldownUntil > 0 && Date.now() < cooldownUntil
|
||||
? {
|
||||
'Retry-After': String(
|
||||
Math.ceil((cooldownUntil - Date.now()) / 1000),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
res.end(body);
|
||||
};
|
||||
@@ -104,7 +146,16 @@ export function celestrakProxy() {
|
||||
if (entry) mem.set(group, entry);
|
||||
}
|
||||
if (entry && now - entry.at < TLE_TTL_MS) {
|
||||
send(200, entry.body, 'HIT');
|
||||
send(200, entry.body, 'HIT', entry);
|
||||
return;
|
||||
}
|
||||
if (Date.now() < cooldownUntil) {
|
||||
send(
|
||||
entry ? 200 : 502,
|
||||
entry?.body || 'celestrak upstream cooling down',
|
||||
entry ? 'STALE-ERROR' : 'NONE',
|
||||
entry,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Stale or missing → refresh, single-flight per group.
|
||||
@@ -118,6 +169,8 @@ export function celestrakProxy() {
|
||||
return fresh;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (Date.now() >= cooldownUntil)
|
||||
cooldownUntil = Date.now() + 60000;
|
||||
console.warn(
|
||||
'[celestrak-proxy] refresh failed — serving cache if any',
|
||||
);
|
||||
@@ -128,9 +181,9 @@ export function celestrakProxy() {
|
||||
}
|
||||
const fresh = await inflight.get(group);
|
||||
if (fresh) {
|
||||
send(200, fresh.body, 'MISS');
|
||||
send(200, fresh.body, 'MISS', fresh);
|
||||
} else if (entry) {
|
||||
send(200, entry.body, 'STALE-ERROR'); // upstream down — stale beats empty
|
||||
send(200, entry.body, 'STALE-ERROR', entry); // upstream down — stale beats empty
|
||||
} else {
|
||||
send(502, 'celestrak fetch failed and no cache available', 'NONE');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Bound an upstream Retry-After delay (seconds or HTTP date). */
|
||||
export function providerCooldownMs(response, now = Date.now()) {
|
||||
const raw = response?.headers?.get('retry-after')?.trim();
|
||||
const seconds = raw && /^\d+(?:\.\d+)?$/.test(raw) ? Number(raw) : NaN;
|
||||
const deadline = raw && !Number.isFinite(seconds) ? Date.parse(raw) : NaN;
|
||||
const delay = Number.isFinite(seconds)
|
||||
? seconds * 1000
|
||||
: Number.isFinite(deadline)
|
||||
? deadline - now
|
||||
: 60000;
|
||||
return Math.min(30 * 60000, Math.max(5000, delay));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from 'node:path';
|
||||
import { providerCooldownMs } from './cooldown.js';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import {
|
||||
readResponseTextCapped,
|
||||
@@ -30,6 +31,8 @@ export function rocketLaunchesProxy() {
|
||||
'launch-library-2-v2.3.json',
|
||||
);
|
||||
let cache = null;
|
||||
let cooldownUntil = 0;
|
||||
let cooldownStatus = 502;
|
||||
let diskLoaded = false;
|
||||
const inFlight = new Map();
|
||||
|
||||
@@ -62,7 +65,18 @@ export function rocketLaunchesProxy() {
|
||||
function send(res, status, body, cacheState) {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': status === 200 ? 'public, max-age=900' : 'no-store',
|
||||
'Cache-Control':
|
||||
status === 200 && cacheState !== 'STALE-ERROR'
|
||||
? 'public, max-age=900'
|
||||
: 'no-store',
|
||||
...(cache ? { 'X-GEV-Cached-At': new Date(cache.at).toISOString() } : {}),
|
||||
...(Date.now() < cooldownUntil
|
||||
? {
|
||||
'Retry-After': String(
|
||||
Math.ceil((cooldownUntil - Date.now()) / 1000),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
'X-GEV-Cache': cacheState,
|
||||
});
|
||||
res.end(body);
|
||||
@@ -75,17 +89,25 @@ export function rocketLaunchesProxy() {
|
||||
signal: AbortSignal.timeout(20000),
|
||||
headers: launchLibraryRequestHeaders(),
|
||||
});
|
||||
const body = await readResponseTextCapped(upstream, maxResponseBytes);
|
||||
if (!upstream.ok) {
|
||||
const deadline = Date.now() + providerCooldownMs(upstream);
|
||||
// Never shorten a longer cooldown that is already in force.
|
||||
if (deadline >= cooldownUntil) {
|
||||
cooldownUntil = deadline;
|
||||
cooldownStatus = upstream.status;
|
||||
}
|
||||
void upstream.body?.cancel().catch(() => {});
|
||||
const error = new Error(`upstream HTTP ${upstream.status}`);
|
||||
error.upstreamStatus = upstream.status;
|
||||
throw error;
|
||||
}
|
||||
const body = await readResponseTextCapped(upstream, maxResponseBytes);
|
||||
const parsed = JSON.parse(body);
|
||||
if (!Array.isArray(parsed?.results))
|
||||
throw new Error('malformed upstream response');
|
||||
const fresh = { at: Date.now(), body };
|
||||
cache = fresh;
|
||||
cooldownUntil = 0;
|
||||
void saveDiskCache(fresh);
|
||||
return fresh;
|
||||
}
|
||||
@@ -103,6 +125,16 @@ export function rocketLaunchesProxy() {
|
||||
return;
|
||||
}
|
||||
const stale = cache;
|
||||
if (now < cooldownUntil) {
|
||||
send(
|
||||
res,
|
||||
stale ? 200 : cooldownStatus,
|
||||
stale?.body ||
|
||||
JSON.stringify({ error: 'Launch Library 2 unavailable' }),
|
||||
stale ? 'STALE-ERROR' : 'NONE',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const request = coalesceProxyRequest(
|
||||
inFlight,
|
||||
'recent-launches',
|
||||
@@ -112,6 +144,10 @@ export function rocketLaunchesProxy() {
|
||||
const fresh = await request.promise;
|
||||
send(res, 200, fresh.body, request.shared ? 'INFLIGHT' : 'MISS');
|
||||
} catch (error) {
|
||||
if (Date.now() >= cooldownUntil) {
|
||||
cooldownUntil = Date.now() + 60000;
|
||||
cooldownStatus = 502;
|
||||
}
|
||||
// Log only a bounded status, never upstream bodies, URLs, or credentials.
|
||||
const status = Number.isInteger(error?.upstreamStatus)
|
||||
? error.upstreamStatus
|
||||
|
||||
@@ -43,14 +43,55 @@ export function ingestAisStreamEnvelope(envelope) {
|
||||
if (!mmsi) return false;
|
||||
|
||||
if (messageType === 'ShipStaticData' || messageType === 'StaticDataReport') {
|
||||
const previous = _aisStreamStatic.get(mmsi) || {};
|
||||
const live = _aisStreamVessels.get(mmsi);
|
||||
const liveName = live?.name === `MMSI ${mmsi}` ? '' : live?.name;
|
||||
const fields =
|
||||
messageType === 'StaticDataReport'
|
||||
? {
|
||||
Name:
|
||||
message.ReportA?.Valid === false
|
||||
? undefined
|
||||
: message.ReportA?.Name,
|
||||
Type:
|
||||
message.ReportB?.Valid === false
|
||||
? undefined
|
||||
: message.ReportB?.ShipType,
|
||||
}
|
||||
: message;
|
||||
// Static reports may be partial (including Class B's separate A/B parts).
|
||||
// Keep known values in the cache as well as in the current live row.
|
||||
const staticData = {
|
||||
name: vesselNameFromAis(metadata, message, _aisStreamStatic.get(mmsi)),
|
||||
type: vesselTypeFromAis(message, _aisStreamStatic.get(mmsi)),
|
||||
destination: stringValue(message.Destination),
|
||||
imo: stringValue(message.ImoNumber ?? message.IMO),
|
||||
// The envelope repeats a cached name even on Class B's type-only part.
|
||||
// Only an actual static name report replaces a known live name.
|
||||
name:
|
||||
stringValue(fields.Name ?? fields.ShipName) ||
|
||||
liveName ||
|
||||
previous.name ||
|
||||
stringValue(metadata.ShipName),
|
||||
type:
|
||||
staticCode(fields.Type ?? fields.ShipType) ||
|
||||
staticCode(live?.type) ||
|
||||
staticCode(previous.type) ||
|
||||
'',
|
||||
destination:
|
||||
stringValue(fields.Destination) ||
|
||||
live?.destination ||
|
||||
previous.destination ||
|
||||
'',
|
||||
imo:
|
||||
staticCode(fields.ImoNumber ?? fields.IMO) ||
|
||||
staticCode(live?.imo) ||
|
||||
staticCode(previous.imo) ||
|
||||
'',
|
||||
};
|
||||
_aisStreamStatic.set(mmsi, staticData);
|
||||
mergeAisStaticIntoLiveVessel(mmsi, staticData);
|
||||
// AISStream repeats the last known position in a static message's
|
||||
// MetaData, but the message has no speed, course or heading and is not a
|
||||
// new fix. Rebuilding a tracked vessel from it would blank its motion and
|
||||
// restamp its position time, so only merge the static fields.
|
||||
if (_aisStreamVessels.has(mmsi)) return true;
|
||||
}
|
||||
|
||||
const lat = numberValue(
|
||||
@@ -191,12 +232,10 @@ function approxMetersBetween(lat1, lon1, lat2, lon2) {
|
||||
function mergeAisStaticIntoLiveVessel(mmsi, staticData) {
|
||||
const existing = _aisStreamVessels.get(mmsi);
|
||||
if (!existing) return;
|
||||
if (staticData.name && (!existing.name || existing.name === `MMSI ${mmsi}`))
|
||||
existing.name = staticData.name;
|
||||
if (staticData.type && !existing.type) existing.type = staticData.type;
|
||||
if (staticData.destination && !existing.destination)
|
||||
existing.destination = staticData.destination;
|
||||
if (staticData.imo && !existing.imo) existing.imo = staticData.imo;
|
||||
if (staticData.name) existing.name = staticData.name;
|
||||
if (staticData.type) existing.type = staticData.type;
|
||||
if (staticData.destination) existing.destination = staticData.destination;
|
||||
if (staticData.imo) existing.imo = staticData.imo;
|
||||
}
|
||||
|
||||
function vesselNameFromAis(metadata, message, staticData = {}) {
|
||||
@@ -266,6 +305,12 @@ function stringValue(value) {
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
// AIS uses zero for unavailable ship type and IMO, not as a replacement value.
|
||||
function staticCode(value) {
|
||||
const number = numberValue(value);
|
||||
return Number.isInteger(number) && number > 0 ? String(number) : '';
|
||||
}
|
||||
|
||||
function numberValue(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const number = Number(value);
|
||||
|
||||
@@ -83,20 +83,6 @@ export function createScreenAnnotationRenderer(
|
||||
const heightCache = new Map();
|
||||
const HEIGHT_CACHE_SOFT = 600; // target size; over this we evict keys not used this frame
|
||||
const HEIGHT_CACHE_HARD = 8000; // absolute ceiling (one big ring + others) — never exceeded
|
||||
// Accepted surface-height range; samples outside it are treated as misses.
|
||||
const MIN_SURFACE_HEIGHT = -430;
|
||||
const MAX_SURFACE_HEIGHT = 9000;
|
||||
const HEIGHT_SAMPLE_BUDGET = 32; // clampToHeight calls per projection pass
|
||||
const HEIGHT_RETRY_MS = 1000; // minimum interval between misses per anchor
|
||||
let cullingVolume = null;
|
||||
const heightBounds = new Cesium.BoundingSphere(new Cesium.Cartesian3(), 9500);
|
||||
// Fair sampling: keys refused by an exhausted budget queue in arrival order,
|
||||
// and each pass reserves budget for the oldest queued keys first, so a fixed
|
||||
// iteration order cannot starve anchors beyond the first budget's worth.
|
||||
let heightQueue = new Set(); // keys refused in earlier passes, oldest first
|
||||
let heightReserved = new Set(); // queued keys this pass serves first
|
||||
let heightRefused = new Set(); // keys refused during this pass
|
||||
let heightBudget = HEIGHT_SAMPLE_BUDGET; // unreserved slots left this pass
|
||||
let projGen = 0; // bumped each projection frame; entries used this frame are "hot"
|
||||
function trimHeightCache() {
|
||||
if (heightCache.size <= HEIGHT_CACHE_SOFT) return;
|
||||
@@ -124,20 +110,10 @@ export function createScreenAnnotationRenderer(
|
||||
return PALETTE[anno.color] || PALETTE.primary;
|
||||
}
|
||||
|
||||
function clearHeights() {
|
||||
heightCache.clear();
|
||||
heightQueue.clear();
|
||||
}
|
||||
|
||||
function heightKey(lon, lat) {
|
||||
return `${lon.toFixed(5)},${lat.toFixed(5)}`;
|
||||
}
|
||||
|
||||
// Best-effort surface height under a coordinate (clamps onto the 3D tiles).
|
||||
// Returns 0 until the tile loads, then caches the validated height.
|
||||
// canSample=false returns the cached height (or 0) without surface sampling.
|
||||
function groundHeight(lon, lat, canSample = true) {
|
||||
const key = heightKey(lon, lat);
|
||||
function groundHeight(lon, lat) {
|
||||
const key = `${lon.toFixed(5)},${lat.toFixed(5)}`;
|
||||
const cached = heightCache.get(key);
|
||||
if (cached && cached.settled) {
|
||||
// Mark hot for this frame + bump to most-recently-used so heights for live
|
||||
@@ -147,20 +123,6 @@ export function createScreenAnnotationRenderer(
|
||||
heightCache.set(key, cached);
|
||||
return cached.h;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (cached && (!canSample || now < cached.nextAttempt)) {
|
||||
cached.gen = projGen;
|
||||
return cached.h;
|
||||
}
|
||||
if (!canSample) return 0;
|
||||
if (heightReserved.has(key)) {
|
||||
heightReserved.delete(key);
|
||||
} else if (heightBudget > 0) {
|
||||
heightBudget--;
|
||||
} else {
|
||||
heightRefused.add(key);
|
||||
return cached?.h || 0;
|
||||
}
|
||||
try {
|
||||
if (
|
||||
scene.clampToHeightSupported &&
|
||||
@@ -171,11 +133,7 @@ export function createScreenAnnotationRenderer(
|
||||
);
|
||||
if (c) {
|
||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
||||
if (
|
||||
Number.isFinite(h) &&
|
||||
h > MIN_SURFACE_HEIGHT &&
|
||||
h < MAX_SURFACE_HEIGHT
|
||||
) {
|
||||
if (Number.isFinite(h) && h > -430 && h < 9000) {
|
||||
heightCache.set(key, { h, settled: true, gen: projGen });
|
||||
trimHeightCache();
|
||||
return h;
|
||||
@@ -186,12 +144,7 @@ export function createScreenAnnotationRenderer(
|
||||
/* tiles not ready */
|
||||
}
|
||||
const fallback = cached ? cached.h : 0;
|
||||
heightCache.set(key, {
|
||||
h: fallback,
|
||||
settled: false,
|
||||
gen: projGen,
|
||||
nextAttempt: now + HEIGHT_RETRY_MS,
|
||||
});
|
||||
heightCache.set(key, { h: fallback, settled: false, gen: projGen });
|
||||
trimHeightCache();
|
||||
return fallback;
|
||||
}
|
||||
@@ -357,7 +310,7 @@ export function createScreenAnnotationRenderer(
|
||||
} catch {
|
||||
/* never inserted */
|
||||
}
|
||||
if (records.size === 0) clearHeights();
|
||||
if (records.size === 0) heightCache.clear();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -409,30 +362,11 @@ export function createScreenAnnotationRenderer(
|
||||
// Anchor at the real surface height (cached + validated), so marks sit on
|
||||
// the ground/building instead of at sea level. The settle-once cache keeps
|
||||
// it stable (no per-frame jitter that earlier broke nearby projections).
|
||||
// A settled height is tested as the actual point below. An unknown height
|
||||
// is rejected before GPU-backed surface sampling only when the whole
|
||||
// accepted height range is proven hidden by one constraint.
|
||||
let height;
|
||||
if (heightCache.get(heightKey(lon, lat))?.settled) {
|
||||
height = groundHeight(lon, lat);
|
||||
} else {
|
||||
if (heightRangeHidden(lon, lat)) return null;
|
||||
// The frustum restricts sampling only; an endpoint outside it still
|
||||
// projects (at its cached or zero height) so a partly visible arrow,
|
||||
// route or ring keeps its on-screen connector and label.
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
0,
|
||||
undefined,
|
||||
heightBounds.center,
|
||||
);
|
||||
const inFrustum =
|
||||
cullingVolume?.computeVisibility(heightBounds) !==
|
||||
Cesium.Intersect.OUTSIDE;
|
||||
height = groundHeight(lon, lat, inFrustum);
|
||||
}
|
||||
const world = Cesium.Cartesian3.fromDegrees(lon, lat, height);
|
||||
const world = Cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
groundHeight(lon, lat),
|
||||
);
|
||||
// Reject points behind the camera (they produce wrapped/extreme coords).
|
||||
Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir);
|
||||
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0)
|
||||
@@ -452,21 +386,6 @@ export function createScreenAnnotationRenderer(
|
||||
return { x: win.x, y: win.y };
|
||||
}
|
||||
|
||||
// Points along one surface normal are linear in height, and each rejection
|
||||
// region (behind the camera plane; the occluder's cone beyond the horizon
|
||||
// plane) is convex. So when both height-range endpoints fail the SAME test,
|
||||
// every intermediate height fails it too. Mixed failures prove nothing.
|
||||
function heightRangeHidden(lon, lat) {
|
||||
const low = Cesium.Cartesian3.fromDegrees(lon, lat, MIN_SURFACE_HEIGHT);
|
||||
const high = Cesium.Cartesian3.fromDegrees(lon, lat, MAX_SURFACE_HEIGHT);
|
||||
const behind = (point) => {
|
||||
Cesium.Cartesian3.subtract(point, scene.camera.positionWC, scratchDir);
|
||||
return Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0;
|
||||
};
|
||||
if (behind(low) && behind(high)) return true;
|
||||
return !occluder.isPointVisible(low) && !occluder.isPointVisible(high);
|
||||
}
|
||||
|
||||
// Tracked-entity z-order: the tracked aircraft is a Cesium billboard in the
|
||||
// CANVAS, which sits BELOW this SVG overlay (z-90, HTML over canvas). So a screen mark
|
||||
// could hide it. After layout, any mark whose ACTUAL projected bounding box (rings,
|
||||
@@ -548,20 +467,6 @@ export function createScreenAnnotationRenderer(
|
||||
|
||||
function projectAll() {
|
||||
if (!records.size) return;
|
||||
// Serve the oldest refused keys first; the rest of the budget is open.
|
||||
heightReserved = new Set();
|
||||
for (const key of heightQueue) {
|
||||
if (heightReserved.size >= HEIGHT_SAMPLE_BUDGET) break;
|
||||
heightReserved.add(key);
|
||||
}
|
||||
heightBudget = HEIGHT_SAMPLE_BUDGET - heightReserved.size;
|
||||
heightRefused = new Set();
|
||||
cullingVolume =
|
||||
scene.camera.frustum?.computeCullingVolume?.(
|
||||
scene.camera.positionWC,
|
||||
scene.camera.directionWC,
|
||||
scene.camera.upWC,
|
||||
) || null;
|
||||
projGen += 1; // new frame: entries touched below are "hot" and survive trimming
|
||||
const trackedRect = trackedEntityRect();
|
||||
occluder.cameraPosition = scene.camera.positionWC;
|
||||
@@ -708,13 +613,6 @@ export function createScreenAnnotationRenderer(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Still-refused queued keys keep their place; new refusals join the back.
|
||||
// Keys no longer requesting a sample (settled, culled, removed) drop out.
|
||||
const nextQueue = new Set();
|
||||
for (const key of heightQueue)
|
||||
if (heightRefused.has(key)) nextQueue.add(key);
|
||||
for (const key of heightRefused) nextQueue.add(key);
|
||||
heightQueue = nextQueue;
|
||||
|
||||
// Keep clustered callouts readable.
|
||||
const placed = [];
|
||||
@@ -795,7 +693,7 @@ export function createScreenAnnotationRenderer(
|
||||
}, 360);
|
||||
records.delete(anno.id);
|
||||
// Board emptied → drop accumulated height samples (natural reset point).
|
||||
if (records.size === 0) clearHeights();
|
||||
if (records.size === 0) heightCache.clear();
|
||||
}
|
||||
|
||||
function sync() {
|
||||
@@ -815,7 +713,7 @@ export function createScreenAnnotationRenderer(
|
||||
/* gone */
|
||||
}
|
||||
records.clear();
|
||||
clearHeights();
|
||||
heightCache.clear();
|
||||
}
|
||||
|
||||
return { add, update, remove, sync, destroy };
|
||||
|
||||
@@ -152,17 +152,10 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
|
||||
});
|
||||
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(-97.5, 31.2, 1000000),
|
||||
directionWC: Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.negate(
|
||||
Cesium.Cartesian3.fromDegrees(-97.5, 31.2),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
positionWC: Cesium.Cartesian3.ZERO,
|
||||
directionWC: Cesium.Cartesian3.ZERO,
|
||||
positionCartographic: { height: 1000 },
|
||||
};
|
||||
t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({ x: 100, y: 100 }));
|
||||
const sampledAnchors = [];
|
||||
const scene = {
|
||||
camera,
|
||||
@@ -333,280 +326,3 @@ test('an add that throws after inserting its group unwinds instead of orphaning
|
||||
);
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('hidden and back-facing annotations do not sample; visible unresolved heights have bounded retries', (t) => {
|
||||
const previous = globalThis.document;
|
||||
const previousRaf = globalThis.requestAnimationFrame;
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (fn) => {
|
||||
fn();
|
||||
return 1;
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.document = previous;
|
||||
globalThis.requestAnimationFrame = previousRaf;
|
||||
});
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1000 });
|
||||
t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({
|
||||
x: 100,
|
||||
y: 100,
|
||||
}));
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100000),
|
||||
directionWC: new Cesium.Cartesian3(-1, 0, 0),
|
||||
positionCartographic: { height: 100000 },
|
||||
moveEnd: new Cesium.Event(),
|
||||
};
|
||||
let samples = 0;
|
||||
const postRender = new Cesium.Event();
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { width: 800, height: 600 },
|
||||
postRender,
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight() {
|
||||
samples++;
|
||||
},
|
||||
requestRender() {},
|
||||
};
|
||||
const renderer = createScreenAnnotationRenderer({ scene, camera });
|
||||
const mark = (id, lon) => ({
|
||||
id,
|
||||
type: 'label',
|
||||
anchor: { lon, lat: 0 },
|
||||
label: id,
|
||||
});
|
||||
renderer.add(mark('far-side', 180));
|
||||
for (let i = 0; i < 100; i++) postRender.raiseEvent();
|
||||
assert.equal(samples, 0);
|
||||
renderer.add(mark('near-side', 0));
|
||||
for (let i = 0; i < 100; i++) postRender.raiseEvent();
|
||||
assert.equal(samples, 1);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
t.mock.timers.tick(1000);
|
||||
postRender.raiseEvent();
|
||||
}
|
||||
assert.equal(samples, 11);
|
||||
camera.moveEnd.raiseEvent();
|
||||
postRender.raiseEvent();
|
||||
assert.equal(samples, 11, 'camera events cannot bypass the retry interval');
|
||||
renderer.update(mark('near-side', 0), { empty: true });
|
||||
t.mock.timers.tick(1000);
|
||||
postRender.raiseEvent();
|
||||
assert.equal(samples, 11, 'hidden records do not sample');
|
||||
camera.directionWC = new Cesium.Cartesian3(1, 0, 0);
|
||||
renderer.update(mark('near-side', 0));
|
||||
postRender.raiseEvent();
|
||||
assert.equal(samples, 11, 'behind-camera anchors do not sample');
|
||||
camera.directionWC = new Cesium.Cartesian3(-1, 0, 0);
|
||||
camera.frustum = {
|
||||
computeCullingVolume: () => ({
|
||||
computeVisibility: () => Cesium.Intersect.OUTSIDE,
|
||||
}),
|
||||
};
|
||||
postRender.raiseEvent();
|
||||
assert.equal(samples, 11, 'outside-frustum anchors do not sample');
|
||||
camera.frustum = null;
|
||||
const before = samples;
|
||||
renderer.add({
|
||||
id: 'large-area',
|
||||
type: 'area',
|
||||
label: 'Area',
|
||||
ring: Array.from({ length: 100 }, (_, i) => [0.001 + i / 10000, 0]),
|
||||
anchor: { lon: 0, lat: 0 },
|
||||
});
|
||||
assert.equal(
|
||||
samples - before,
|
||||
32,
|
||||
'a projection pass caps synchronous surface work',
|
||||
);
|
||||
renderer.destroy();
|
||||
assert.equal(camera.moveEnd.numberOfListeners, 0);
|
||||
assert.equal(postRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
function sampledLon(world) {
|
||||
const carto = Cesium.Cartographic.fromCartesian(world);
|
||||
return Number(Cesium.Math.toDegrees(carto.longitude).toFixed(5));
|
||||
}
|
||||
|
||||
function installDom(t) {
|
||||
const previous = globalThis.document;
|
||||
const previousRaf = globalThis.requestAnimationFrame;
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (fn) => {
|
||||
fn();
|
||||
return 1;
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.document = previous;
|
||||
globalThis.requestAnimationFrame = previousRaf;
|
||||
});
|
||||
}
|
||||
|
||||
function squareFrustum() {
|
||||
const frustum = new Cesium.PerspectiveFrustum();
|
||||
frustum.fov = Cesium.Math.toRadians(60);
|
||||
frustum.aspectRatio = 1;
|
||||
frustum.near = 1;
|
||||
frustum.far = 1e8;
|
||||
return frustum;
|
||||
}
|
||||
|
||||
test('an arrow with one endpoint outside a real frustum keeps its visible connector', (t) => {
|
||||
installDom(t);
|
||||
t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', (_scene, world) =>
|
||||
sampledLon(world) > 0.1 ? { x: 1100, y: 400 } : { x: 400, y: 400 },
|
||||
);
|
||||
// 5 km straight down over (0, 0): east is +Y, north is +Z in ECEF here.
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 5000),
|
||||
directionWC: new Cesium.Cartesian3(-1, 0, 0),
|
||||
upWC: new Cesium.Cartesian3(0, 0, 1),
|
||||
positionCartographic: { height: 5000 },
|
||||
frustum: squareFrustum(),
|
||||
};
|
||||
const sampled = [];
|
||||
const postRender = new Cesium.Event();
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { width: 800, height: 800 },
|
||||
postRender,
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight(world) {
|
||||
sampled.push(sampledLon(world));
|
||||
return world;
|
||||
},
|
||||
};
|
||||
// The 0.2-degree endpoint (~22 km east) is well beyond the side plane plus
|
||||
// the 9.5 km height-bound radius, yet in front of the camera and the horizon.
|
||||
const far = Cesium.Cartesian3.fromDegrees(0.2, 0, 0);
|
||||
const volume = camera.frustum.computeCullingVolume(
|
||||
camera.positionWC,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
);
|
||||
assert.equal(
|
||||
volume.computeVisibility(new Cesium.BoundingSphere(far, 9500)),
|
||||
Cesium.Intersect.OUTSIDE,
|
||||
);
|
||||
const renderer = createScreenAnnotationRenderer({ scene, camera });
|
||||
renderer.add({
|
||||
id: 'partial-arrow',
|
||||
type: 'arrow',
|
||||
label: 'Ridge',
|
||||
anchor: { lon: 0, lat: 0 },
|
||||
to: { lon: 0.2, lat: 0 },
|
||||
});
|
||||
postRender.raiseEvent();
|
||||
const { group } = findAnnotationGroup(globalThis.document);
|
||||
assert.equal(group.style.display, '', 'the arrow stays drawn');
|
||||
const path = group.children.find((child) => child.classList.contains('gev-anno-arrow'));
|
||||
assert.match(path.getAttribute('d'), /^M 400\.0 400\.0 Q .* 1100\.0 400\.0$/);
|
||||
assert.deepEqual([...new Set(sampled)], [0], 'only the in-frustum endpoint samples');
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('a low-angle cached roof stays visible when range endpoints fail different tests', (t) => {
|
||||
installDom(t);
|
||||
t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({ x: 400, y: 380 }));
|
||||
// Camera 1 m above the ellipsoid, heading east, pitched 25 degrees down.
|
||||
const pitch = Cesium.Math.toRadians(25);
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 1),
|
||||
directionWC: new Cesium.Cartesian3(-Math.sin(pitch), Math.cos(pitch), 0),
|
||||
upWC: new Cesium.Cartesian3(Math.cos(pitch), Math.sin(pitch), 0),
|
||||
positionCartographic: { height: 1 },
|
||||
frustum: squareFrustum(),
|
||||
};
|
||||
const roofLon = 4000 / 111319.49;
|
||||
let samples = 0;
|
||||
const postRender = new Cesium.Event();
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { width: 800, height: 800 },
|
||||
postRender,
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight() {
|
||||
samples++;
|
||||
return Cesium.Cartesian3.fromDegrees(roofLon, 0, 20);
|
||||
},
|
||||
};
|
||||
// Sanity: height 0 is beyond the horizon and 9000 m is behind the camera,
|
||||
// while the 20 m roof itself is in front and above the horizon.
|
||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, camera.positionWC);
|
||||
const ahead = (h) => {
|
||||
const p = Cesium.Cartesian3.fromDegrees(roofLon, 0, h);
|
||||
const v = Cesium.Cartesian3.subtract(p, camera.positionWC, new Cesium.Cartesian3());
|
||||
return Cesium.Cartesian3.dot(v, camera.directionWC) > 0;
|
||||
};
|
||||
assert.equal(occluder.isPointVisible(Cesium.Cartesian3.fromDegrees(roofLon, 0, 0)), false);
|
||||
assert.equal(ahead(9000), false);
|
||||
assert.equal(ahead(20), true);
|
||||
assert.equal(occluder.isPointVisible(Cesium.Cartesian3.fromDegrees(roofLon, 0, 20)), true);
|
||||
|
||||
const renderer = createScreenAnnotationRenderer({ scene, camera });
|
||||
renderer.add({ id: 'roof', type: 'label', label: 'Roof', anchor: { lon: roofLon, lat: 0 } });
|
||||
const { group } = findAnnotationGroup(globalThis.document);
|
||||
assert.equal(samples, 1, 'the unknown height is sampled, not culled');
|
||||
assert.equal(group.style.display, '');
|
||||
for (let i = 0; i < 5; i++) postRender.raiseEvent();
|
||||
assert.equal(samples, 1, 'the settled height is reused');
|
||||
assert.equal(group.style.display, '', 'the cached roof point is tested and shown');
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('height retries beyond the first budget are served under sparse passes', (t) => {
|
||||
installDom(t);
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1000 });
|
||||
t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({ x: 100, y: 100 }));
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100000),
|
||||
directionWC: new Cesium.Cartesian3(-1, 0, 0),
|
||||
positionCartographic: { height: 100000 },
|
||||
};
|
||||
const lons = Array.from({ length: 33 }, (_, i) => Number(((i + 1) / 1000).toFixed(3)));
|
||||
const last = lons.at(-1);
|
||||
let tilesReady = false;
|
||||
let pass = [];
|
||||
const postRender = new Cesium.Event();
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { width: 800, height: 600 },
|
||||
postRender,
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight(world) {
|
||||
const lon = sampledLon(world);
|
||||
pass.push(lon);
|
||||
return tilesReady && lon === last ? Cesium.Cartesian3.fromDegrees(lon, 0, 12) : undefined;
|
||||
},
|
||||
};
|
||||
const renderer = createScreenAnnotationRenderer({ scene, camera });
|
||||
for (const lon of lons) {
|
||||
renderer.add({ id: `p${lon}`, type: 'label', label: 'P', anchor: { lon, lat: 0 } });
|
||||
}
|
||||
assert.ok(pass.includes(last), 'every point gets its initial miss');
|
||||
tilesReady = true; // tiles under the final point arrive after its miss
|
||||
const lastSamples = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
t.mock.timers.tick(1000);
|
||||
pass = [];
|
||||
postRender.raiseEvent();
|
||||
assert.ok(pass.length <= 32, `pass ${i} keeps the 32-sample budget`);
|
||||
lastSamples.push(pass.filter((lon) => lon === last).length);
|
||||
}
|
||||
assert.ok(lastSamples.slice(0, 2).includes(1), 'the 33rd point is served within two passes');
|
||||
assert.equal(lastSamples.at(-1), 0, 'and then stays settled');
|
||||
// Points refused in one pass are served first in the next: every point is
|
||||
// retried within any two consecutive sparse passes.
|
||||
pass = [];
|
||||
t.mock.timers.tick(1000);
|
||||
postRender.raiseEvent();
|
||||
const firstPass = pass;
|
||||
pass = [];
|
||||
t.mock.timers.tick(1000);
|
||||
postRender.raiseEvent();
|
||||
const served = new Set([...firstPass, ...pass]);
|
||||
for (const lon of lons.slice(0, -1)) assert.ok(served.has(lon), `${lon} retried`);
|
||||
renderer.destroy();
|
||||
});
|
||||
@@ -13,6 +13,7 @@ export function createApplicationData({
|
||||
// Initialize data layer manager
|
||||
const dataManager = new LayerLifecycle(viewer, {
|
||||
allowQaRegistration,
|
||||
visibilityTarget: document,
|
||||
getSourceAvailability: catalog?.getSourceAvailability,
|
||||
});
|
||||
defer(async () => {
|
||||
|
||||
@@ -72,3 +72,17 @@ test('the defaults are Mapillary (given its source), Street View, KartaView and
|
||||
// Without the application's Street Level source there are none.
|
||||
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
|
||||
});
|
||||
|
||||
test('every built-in provider is exported, so hosts can choose their own list', async () => {
|
||||
const google = await import('gods-eye-view/layers/street-level/google');
|
||||
const kartaview = await import('gods-eye-view/layers/street-level/kartaview');
|
||||
const panoramax = await import('gods-eye-view/layers/street-level/panoramax');
|
||||
const layer = createApplicationStreetLevel({
|
||||
providers: [
|
||||
google.createGoogleProvider({ getApiKey: () => null }),
|
||||
kartaview.createKartaViewProvider(),
|
||||
panoramax.createPanoramaxProvider(),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(layer.providerIds, ['google', 'kartaview', 'panoramax']);
|
||||
});
|
||||
@@ -137,6 +137,11 @@ export function createApplicationViewer({ container, creditContainer }) {
|
||||
);
|
||||
try {
|
||||
viewer.targetFrameRate = 60;
|
||||
// A phone pinch spans fewer screen pixels than desktop zoom gestures.
|
||||
// Keep Cesium's distance-aware zoom and collision limits, with less travel
|
||||
// required on touch-first screens. Mouse/trackpad sensitivity stays native.
|
||||
if (globalThis.matchMedia?.('(pointer: coarse)').matches)
|
||||
viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
|
||||
// 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.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -223,7 +223,7 @@ test('validated voice camera destinations share the UI navigation authority faca
|
||||
'move camera',
|
||||
);
|
||||
ordered(move, [
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))",
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
|
||||
'const start = () => {',
|
||||
/const preserveCameraFlight =\s*motion === 'orbit'/,
|
||||
'runNavigation(start, { preserveCameraFlight })',
|
||||
|
||||
+300
-12
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* One motion at a time, driven per clock tick. `once` = bounded eased nudge;
|
||||
* `continuous` runs until move_camera{stop}, ANY manual camera input on the
|
||||
* canvas (pointerdown/wheel — the cancelFlight reflex), or a navigation tool
|
||||
* canvas (pointerdown/wheel/touchstart — the cancelFlight reflex), or a navigation tool
|
||||
* starts (the shared UI navigation policy releases this owner before the new
|
||||
* destination mutates the camera). fly_route is a
|
||||
* continuous motion in the same slot: a cinematic dolly along an existing
|
||||
@@ -13,14 +13,24 @@
|
||||
* Orbit circles the current screen-center ground target holding range and
|
||||
* pitch while advancing heading (per-frame camera.lookAt; the lookAt
|
||||
* transform is cleared on every stop so manual control returns cleanly).
|
||||
* Globe rotation turns the world-frame camera about its screen-up axis through
|
||||
* Earth's center, preserving range and framing. Left/right name the visible
|
||||
* globe motion, opposite to the camera's travel around Earth.
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { GLOBE_VIEW } from './locations.js';
|
||||
import {
|
||||
projectEarthDiscToViewport,
|
||||
earthDiscScreenRadius,
|
||||
getKeyholeGeometry,
|
||||
} from './celestialRing.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
} from './renderGovernor.js';
|
||||
|
||||
let _isCockpitActive = null;
|
||||
/** °/s by speed word — orbit; pan uses fractions of view height/s. */
|
||||
const ORBIT_DEG_S = { slow: 2, normal: 6, fast: 15 };
|
||||
const TILT_DEG_S = { slow: 4, normal: 10, fast: 20 };
|
||||
@@ -952,6 +962,7 @@ let _viewer = null;
|
||||
let _getTarget = null;
|
||||
let _active = null; // { kind, motion, direction, speed, mode, motionId, ...state }
|
||||
let _tickRemover = null;
|
||||
let _trackingRemover = null;
|
||||
let _inputRemovers = [];
|
||||
/** Monotonic id for the motion currently running; 0 means "no motion". */
|
||||
let _motionSeq = 0;
|
||||
@@ -1162,7 +1173,11 @@ function onTick() {
|
||||
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') {
|
||||
} else if (
|
||||
m.kind === 'tilt' ||
|
||||
m.kind === 'rotate' ||
|
||||
m.kind === 'rotate_globe'
|
||||
) {
|
||||
const degS = TILT_DEG_S[m.speed];
|
||||
const stepRad =
|
||||
m.mode === 'once'
|
||||
@@ -1172,7 +1187,38 @@ function onTick() {
|
||||
(scale - (m.lastScale || 0))
|
||||
: Cesium.Math.toRadians(degS) * dt;
|
||||
m.lastScale = scale;
|
||||
if (m.kind === 'tilt') {
|
||||
if (m.kind === 'rotate_globe') {
|
||||
// Tracking can acquire the camera without going through navigation.
|
||||
// Yield without resetting its pose/frame: the world-space arc proof
|
||||
// only describes Cesium's rotation while the frame is identity.
|
||||
if (
|
||||
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
|
||||
_viewer.trackedEntity ||
|
||||
_isCockpitActive?.() ||
|
||||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
|
||||
) {
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
return;
|
||||
}
|
||||
// Cesium rotates both position and basis about the reference-frame
|
||||
// origin. The captured screen-up axis keeps left/right horizontal even
|
||||
// in a tilted or rolled view; a positive angle moves the globe right.
|
||||
const angle = m.direction === 'left' ? -stepRad : stepRad;
|
||||
if (globeRotationFitsKeyhole() !== true) {
|
||||
interruptCameraMotion('globe-framing-changed');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!globeRotationHasClearance(cam.positionWC) ||
|
||||
Math.abs(
|
||||
Cesium.Cartesian3.magnitude(cam.positionWC) - m.rotationRadius,
|
||||
) > 0.01
|
||||
) {
|
||||
interruptCameraMotion('globe-surface-limit');
|
||||
return;
|
||||
}
|
||||
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.
|
||||
const next = cam.pitch + (up ? stepRad : -stepRad);
|
||||
@@ -1206,6 +1252,133 @@ function onTick() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */
|
||||
function globeRotationHasClearance(position) {
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
const { minimumRadius, maximumRadius } = ellipsoid;
|
||||
const radius = Cesium.Cartesian3.magnitude(position);
|
||||
return (
|
||||
Number.isFinite(minimumRadius) &&
|
||||
minimumRadius > 0 &&
|
||||
Number.isFinite(maximumRadius) &&
|
||||
maximumRadius >= minimumRadius &&
|
||||
Number.isFinite(radius) &&
|
||||
radius > maximumRadius * (1 + 1e-12)
|
||||
);
|
||||
}
|
||||
|
||||
/** Use the existing WGS84/CSS-pixel projection; null means unsupported, not close-up. */
|
||||
function globeRotationFitsKeyhole(camera = _viewer.camera) {
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
if (!Cesium.Cartesian3.equals(ellipsoid.radii, Cesium.Ellipsoid.WGS84.radii))
|
||||
return null;
|
||||
const canvas = _viewer.scene.canvas;
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
const frustum = camera.frustum;
|
||||
if (
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0 ||
|
||||
!(frustum instanceof Cesium.PerspectiveFrustum) ||
|
||||
!Number.isFinite(frustum.near) ||
|
||||
frustum.near <= 0 ||
|
||||
!Number.isFinite(frustum.far) ||
|
||||
frustum.far <= frustum.near ||
|
||||
frustum.xOffset !== 0 ||
|
||||
frustum.yOffset !== 0
|
||||
)
|
||||
return null;
|
||||
try {
|
||||
const cam = camera;
|
||||
const distance = Cesium.Cartesian3.magnitude(cam.positionWC);
|
||||
const sphereRadius = ellipsoid.maximumRadius;
|
||||
if (!Number.isFinite(distance) || distance <= 0) return null;
|
||||
if (distance <= sphereRadius) return false; // Near-surface polar views are close-up too.
|
||||
const radiusPx = earthDiscScreenRadius(distance, height, frustum.fovy);
|
||||
if (!radiusPx) return null;
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
// The known nadir reset probe uses the same exact cone below; projecting
|
||||
// through SceneTransforms would read the live scene.camera instead.
|
||||
if (
|
||||
camera === _viewer.camera &&
|
||||
!projectEarthDiscToViewport(_viewer, width, height)
|
||||
) {
|
||||
// A near-surface horizon view cannot fit even when pointed at Earth;
|
||||
// an orbital camera looking away is unsupported, not a close-up offer.
|
||||
return radiusPx > Math.min(keyhole.radius, width / 2, height / 2)
|
||||
? false
|
||||
: null;
|
||||
}
|
||||
// The shared disc is an overlay approximation off-axis. Bound the sphere
|
||||
// against the existing keyhole cone. Canvas-edge clipping does not change
|
||||
// framing during rigid globe rotation and is not a clearance condition,
|
||||
// without changing the UI projection or defining another keyhole/mask.
|
||||
const toCenter = Cesium.Cartesian3.negate(
|
||||
cam.positionWC,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const depth = Cesium.Cartesian3.dot(toCenter, cam.directionWC);
|
||||
const right = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.rightWC));
|
||||
const up = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.upWC));
|
||||
const focalY = height / (2 * Math.tan(frustum.fovy / 2));
|
||||
const focalX =
|
||||
width / (2 * Math.tan(frustum.fovy / 2) * frustum.aspectRatio);
|
||||
if (
|
||||
![depth, right, up, focalX, focalY].every(Number.isFinite) ||
|
||||
focalX <= 0 ||
|
||||
focalY <= 0
|
||||
)
|
||||
return null;
|
||||
const halfAngle = Math.asin(sphereRadius / distance);
|
||||
const centerAngle = Math.atan2(Math.hypot(right, up), depth);
|
||||
const keyholeAngle = Math.atan(keyhole.radius / Math.max(focalX, focalY));
|
||||
// No overlay visibility hysteresis: a fully contained smaller globe needs no reset.
|
||||
const framed = centerAngle + halfAngle <= keyholeAngle;
|
||||
if (!framed) return false;
|
||||
// Projection alone does not prove that a tiny globe is within depth planes.
|
||||
if (
|
||||
depth - sphereRadius < frustum.near ||
|
||||
depth + sphereRadius > frustum.far
|
||||
)
|
||||
return null;
|
||||
return true;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check the unchanged reset destination without moving the live camera. */
|
||||
function globeResetFitsKeyhole() {
|
||||
try {
|
||||
const camera = Cesium.Camera.clone(_viewer.camera);
|
||||
const { longitude, latitude } = _viewer.camera.positionCartographic;
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
longitude,
|
||||
latitude,
|
||||
GLOBE_VIEW.heightM,
|
||||
),
|
||||
orientation: {
|
||||
heading: 0,
|
||||
pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg),
|
||||
roll: 0,
|
||||
},
|
||||
endTransform: Cesium.Matrix4.IDENTITY,
|
||||
});
|
||||
return globeRotationFitsKeyhole(camera) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Release only globe rotation when a new camera intent takes ownership. */
|
||||
export function interruptGlobeRotation(reason = 'camera-intent') {
|
||||
if (_active?.kind === 'rotate_globe') return interruptCameraMotion(reason);
|
||||
return { wasActive: false, reason, leveled: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the module to the viewer once (idempotent). The view-target getter is
|
||||
* optional so a non-voice caller (the Directions FLY chip) can arm route
|
||||
@@ -1218,24 +1391,42 @@ function onTick() {
|
||||
* call brings none, so a callback from the old viewer can never answer for the
|
||||
* new one.
|
||||
*/
|
||||
export function initCameraVerbs(viewer, getViewTargetCartesian = null) {
|
||||
export function initCameraVerbs(
|
||||
viewer,
|
||||
getViewTargetCartesian = null,
|
||||
{ isCockpitActive } = {},
|
||||
) {
|
||||
const nextTarget =
|
||||
typeof getViewTargetCartesian === 'function'
|
||||
? getViewTargetCartesian
|
||||
: null;
|
||||
if (_viewer === viewer) {
|
||||
if (nextTarget) _getTarget = nextTarget;
|
||||
if (typeof isCockpitActive === 'function')
|
||||
_isCockpitActive = isCockpitActive;
|
||||
return;
|
||||
}
|
||||
// A globe motion belongs to the viewer whose world pose it captured.
|
||||
if (_active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('viewer-replaced');
|
||||
_getTarget = nextTarget;
|
||||
_isCockpitActive =
|
||||
typeof isCockpitActive === 'function' ? isCockpitActive : null;
|
||||
_viewer = viewer;
|
||||
if (_tickRemover) _tickRemover();
|
||||
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
|
||||
if (_trackingRemover) _trackingRemover();
|
||||
// Cockpit can acquire then clear tracking before the next clock tick.
|
||||
// Yield on acquisition itself without changing legacy camera motions.
|
||||
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
|
||||
if (entity && _active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
});
|
||||
for (const rm of _inputRemovers) rm();
|
||||
_inputRemovers = [];
|
||||
const canvas = viewer.scene.canvas;
|
||||
// ANY manual camera input reclaims control — the cancelFlight reflex.
|
||||
for (const evt of ['pointerdown', 'wheel']) {
|
||||
for (const evt of ['pointerdown', 'touchstart', 'wheel']) {
|
||||
const h = () => interruptCameraMotion('manual-input');
|
||||
canvas.addEventListener(evt, h, { passive: true });
|
||||
_inputRemovers.push(() => canvas.removeEventListener(evt, h));
|
||||
@@ -1256,7 +1447,11 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
const speed = ORBIT_DEG_S[String(args.speed || 'normal').toLowerCase()]
|
||||
? String(args.speed || 'normal').toLowerCase()
|
||||
: 'normal';
|
||||
const mode = args.mode === 'continuous' ? 'continuous' : 'once';
|
||||
const mode =
|
||||
args.mode === 'continuous' ||
|
||||
(motion === 'rotate_globe' && args.mode === undefined)
|
||||
? 'continuous'
|
||||
: 'once';
|
||||
if (motion === 'stop') {
|
||||
const wasActiveBeforeHandoff = Boolean(_active);
|
||||
const stop = () => {
|
||||
@@ -1270,15 +1465,16 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
};
|
||||
return typeof runNavigation === 'function' ? runNavigation(stop) : stop();
|
||||
}
|
||||
if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion)) {
|
||||
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, or stop.`,
|
||||
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion !== 'orbit' &&
|
||||
motion !== 'rotate_globe' &&
|
||||
!['left', 'right', 'up', 'down'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
@@ -1294,6 +1490,17 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
error: 'rotate needs left or right.',
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion === 'rotate_globe' &&
|
||||
direction !== null &&
|
||||
!['left', 'right'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'rotate_globe needs left or right.',
|
||||
};
|
||||
}
|
||||
if (motion === 'tilt') {
|
||||
// Honest limits: at the clamp a tilt is a no-op — say so instead of
|
||||
// reporting success and letting the model gaslight itself (field test).
|
||||
@@ -1318,9 +1525,9 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
}
|
||||
}
|
||||
if (
|
||||
motion === 'orbit' &&
|
||||
_viewer?.trackedEntity &&
|
||||
typeof runNavigation !== 'function'
|
||||
(motion === 'rotate_globe' ||
|
||||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
|
||||
_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
|
||||
@@ -1332,9 +1539,74 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want a free orbit.`,
|
||||
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want ${motion === 'rotate_globe' ? 'to rotate the globe' : 'a free orbit'}.`,
|
||||
};
|
||||
}
|
||||
let globePose;
|
||||
if (motion === 'rotate_globe') {
|
||||
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'Globe rotation requires a 3D view.',
|
||||
};
|
||||
}
|
||||
const cam = _viewer.camera;
|
||||
globePose = {
|
||||
position: Cesium.Cartesian3.clone(cam.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(cam.directionWC),
|
||||
up: Cesium.Cartesian3.normalize(cam.upWC, new Cesium.Cartesian3()),
|
||||
right: Cesium.Cartesian3.clone(cam.rightWC),
|
||||
};
|
||||
if (_isCockpitActive?.())
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'camera-owned',
|
||||
error: 'Exit Cockpit before rotating the globe.',
|
||||
};
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
if (!(
|
||||
Number.isFinite(ellipsoid.minimumRadius) &&
|
||||
ellipsoid.minimumRadius > 0 &&
|
||||
Number.isFinite(ellipsoid.maximumRadius) &&
|
||||
ellipsoid.maximumRadius >= ellipsoid.minimumRadius &&
|
||||
Number.isFinite(Cesium.Cartesian3.magnitude(globePose.position))
|
||||
))
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsafe-ellipsoid',
|
||||
error:
|
||||
'Globe rotation requires a finite supported ellipsoid and a clear globe view.',
|
||||
};
|
||||
const fits = globeRotationFitsKeyhole();
|
||||
if (fits === null)
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsupported-view',
|
||||
error: 'Globe rotation requires a supported perspective view.',
|
||||
};
|
||||
if (!fits) {
|
||||
const canReset = globeResetFitsKeyhole();
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: canReset ? 'globe-view-required' : 'globe-reset-unavailable',
|
||||
error: canReset
|
||||
? 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’'
|
||||
: 'Zoom out further to fit the globe inside the keyhole; the Globe View preset does not fit this viewport.',
|
||||
};
|
||||
}
|
||||
if (!globeRotationHasClearance(globePose.position))
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsafe-ellipsoid',
|
||||
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
|
||||
};
|
||||
}
|
||||
const start = () => {
|
||||
interruptCameraMotion('replaced');
|
||||
const state = {
|
||||
@@ -1343,6 +1615,22 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
speed,
|
||||
mode,
|
||||
};
|
||||
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 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.
|
||||
Cesium.Cartesian3.clone(globePose.position, cam.position);
|
||||
Cesium.Cartesian3.clone(globePose.direction, cam.direction);
|
||||
Cesium.Cartesian3.clone(globePose.up, cam.up);
|
||||
Cesium.Cartesian3.clone(globePose.right, cam.right);
|
||||
state.rotationAxis = globePose.up;
|
||||
state.rotationRadius = Cesium.Cartesian3.magnitude(globePose.position);
|
||||
}
|
||||
if (motion === 'orbit') {
|
||||
if (_viewer.scene.tweens.length > 0) {
|
||||
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm
|
||||
|
||||
@@ -6,6 +6,15 @@ export function normalizeHeading(value) {
|
||||
return ((value % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-digit heading readout, 000–359.
|
||||
* Rounding 359.5° and above lands on north (000), not 360.
|
||||
*/
|
||||
export function formatHeadingReadout(degrees) {
|
||||
if (!Number.isFinite(degrees)) return '---';
|
||||
return String(Math.round(normalizeHeading(degrees)) % 360).padStart(3, '0');
|
||||
}
|
||||
|
||||
/** Advance a displayed heading along the shortest arc without exceeding a slew rate. */
|
||||
export function slewHeading(current, target, maxStepDeg) {
|
||||
const from = normalizeHeading(current);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
formatCockpitContextScope,
|
||||
formatCockpitEmergency,
|
||||
formatCompassDivision,
|
||||
formatHeadingReadout,
|
||||
formatSpeedRulerTick,
|
||||
normalizeHeading,
|
||||
relativeBearing,
|
||||
@@ -155,6 +156,14 @@ test('compass labels use cardinals where exact and degrees elsewhere', () => {
|
||||
assert.equal(formatCompassDivision(30), '030');
|
||||
});
|
||||
|
||||
test('a heading that rounds to north reads 000, not 360', () => {
|
||||
assert.equal(formatHeadingReadout(359.6), '000');
|
||||
assert.equal(formatHeadingReadout(359.4), '359');
|
||||
assert.equal(formatHeadingReadout(0), '000');
|
||||
assert.equal(formatHeadingReadout(10.2), '010');
|
||||
assert.equal(formatHeadingReadout(Number.NaN), '---');
|
||||
});
|
||||
|
||||
test('great-circle bearing resolves cardinal directions', () => {
|
||||
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 1, 0) - 0) < 0.001);
|
||||
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 0, 1) - 90) < 0.001);
|
||||
|
||||
@@ -1011,6 +1011,34 @@ test('vessel interaction wire: flights-owned pick preserves vessel selection', (
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel touch interaction yields to the first nearby flight behind map tiles', () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: () => ({ matches: true }),
|
||||
});
|
||||
registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3');
|
||||
const tile = { primitive: { isCesium3DTileset: true } };
|
||||
const harness = installWireHarness(tile);
|
||||
let drills = 0;
|
||||
harness.viewer.scene.drillPick = (_position, limit, width, height) => {
|
||||
drills++;
|
||||
assert.deepEqual([limit, width, height], [8, 14, 14]);
|
||||
return [tile, { id: 'a1b2c3' }];
|
||||
};
|
||||
try {
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(drills, 1);
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||||
assert.equal(harness.trail.clearCalls, 0);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
unregisterPickOwner('flights');
|
||||
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
|
||||
else delete globalThis.matchMedia;
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () => {
|
||||
registerPickOwner('cctv', (pickedId) => pickedId === 'atx-cam-3');
|
||||
const harness = installWireHarness({ id: 'atx-cam-3' });
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
AISSTREAM_STALE_MS,
|
||||
aisStreamRows,
|
||||
ingestAisStreamEnvelope,
|
||||
readAisTrack,
|
||||
} from '../../server/providers/vessels/ais-store.js';
|
||||
|
||||
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
|
||||
@@ -63,3 +65,196 @@ test('the fastest encodable reading below the unavailable code is kept', () => {
|
||||
assert.equal(row.speed, 102.2);
|
||||
assert.equal(row.course, 359.9);
|
||||
});
|
||||
|
||||
test('a static data report keeps the motion and fix time of a tracked vessel', () => {
|
||||
ingestAisStreamEnvelope(
|
||||
positionReport('244690000', { Sog: 12.4, Cog: 86.7, TrueHeading: 87 }),
|
||||
);
|
||||
// AISStream's MetaData carries the last known position on every message,
|
||||
// static data included.
|
||||
assert.equal(
|
||||
ingestAisStreamEnvelope({
|
||||
MessageType: 'ShipStaticData',
|
||||
Message: {
|
||||
ShipStaticData: {
|
||||
UserID: 244690000,
|
||||
Name: 'EXAMPLE VESSEL',
|
||||
Type: 70,
|
||||
Destination: 'ROTTERDAM',
|
||||
ImoNumber: 9876543,
|
||||
},
|
||||
},
|
||||
MetaData: {
|
||||
MMSI: '244690000',
|
||||
latitude: 53.40879,
|
||||
longitude: 6.19741,
|
||||
time_utc: '2026-09-17 20:05:00.000000000 +0000 UTC',
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const row = rowFor('244690000');
|
||||
assert.equal(row.speed, 12.4);
|
||||
assert.equal(row.course, 86.7);
|
||||
assert.equal(row.heading, 87);
|
||||
assert.equal(row.last_position_UTC, '2026-09-17T20:00:00.000Z');
|
||||
assert.equal(row.destination, 'ROTTERDAM');
|
||||
assert.equal(row.imo, '9876543');
|
||||
});
|
||||
|
||||
// AISStream carries both Class B parts; the unused part has Valid: false.
|
||||
const classBStatic = (part, value) => ({
|
||||
MessageID: 24,
|
||||
RepeatIndicator: 0,
|
||||
Valid: true,
|
||||
Reserved: 0,
|
||||
PartNumber: part === 'B',
|
||||
ReportA: { Valid: part === 'A', Name: part === 'A' ? value : '' },
|
||||
ReportB: {
|
||||
Valid: part === 'B',
|
||||
ShipType: part === 'B' ? value : 0,
|
||||
VendorIDName: '',
|
||||
VenderIDModel: 0,
|
||||
VenderIDSerial: 0,
|
||||
CallSign: '',
|
||||
Dimension: { A: 0, B: 0, C: 0, D: 0 },
|
||||
FixType: 0,
|
||||
Spare: 0,
|
||||
},
|
||||
});
|
||||
|
||||
for (const [index, scenario] of [
|
||||
{
|
||||
type: 'ShipStaticData',
|
||||
initial: [
|
||||
{
|
||||
Name: 'OLD VESSEL',
|
||||
Type: 70,
|
||||
Destination: 'ROTTERDAM',
|
||||
ImoNumber: 9876543,
|
||||
},
|
||||
],
|
||||
updates: [
|
||||
{
|
||||
Name: 'NEW VESSEL',
|
||||
Type: 80,
|
||||
Destination: 'HAMBURG',
|
||||
ImoNumber: 9876544,
|
||||
},
|
||||
],
|
||||
empty: { Name: ' ', Type: 0, Destination: '', ImoNumber: 0 },
|
||||
expected: {
|
||||
name: 'NEW VESSEL',
|
||||
type: '80',
|
||||
destination: 'HAMBURG',
|
||||
imo: '9876544',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'StaticDataReport',
|
||||
initial: [classBStatic('A', 'OLD VESSEL'), classBStatic('B', 36)],
|
||||
updates: [classBStatic('A', 'NEW VESSEL'), classBStatic('B', 37)],
|
||||
empty: classBStatic('A', ''),
|
||||
expected: { name: 'NEW VESSEL', type: '37', destination: '', imo: '' },
|
||||
},
|
||||
].entries()) {
|
||||
test(`${scenario.type} replaces static fields without inventing a position fix`, (t) => {
|
||||
const mmsi = String(244690100 + index);
|
||||
const start = Date.parse('2026-09-17T20:00:00Z');
|
||||
let now = start;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const position = (
|
||||
lat,
|
||||
body = { Sog: 12.4, Cog: 86.7, TrueHeading: 87 },
|
||||
) => {
|
||||
const envelope = positionReport(mmsi, body);
|
||||
Object.assign(envelope.MetaData, {
|
||||
latitude: lat,
|
||||
time_utc: new Date(now).toISOString(),
|
||||
});
|
||||
return ingestAisStreamEnvelope(envelope);
|
||||
};
|
||||
const sendStatic = (body) =>
|
||||
ingestAisStreamEnvelope({
|
||||
MessageType: scenario.type,
|
||||
Message: { [scenario.type]: { UserID: Number(mmsi), ...body } },
|
||||
MetaData: {
|
||||
MMSI: mmsi,
|
||||
ShipName: 'STALE HEADER',
|
||||
latitude: 54,
|
||||
longitude: 7,
|
||||
time_utc: new Date(now).toISOString(),
|
||||
},
|
||||
});
|
||||
position(53.40879);
|
||||
for (const body of scenario.initial) assert.equal(sendStatic(body), true);
|
||||
now += 60_000;
|
||||
position(53.41879);
|
||||
const before = rowFor(mmsi);
|
||||
const track = readAisTrack(mmsi);
|
||||
assert.equal(track.length, 2);
|
||||
|
||||
now += 5 * 60_000;
|
||||
for (const body of scenario.updates) assert.equal(sendStatic(body), true);
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||
assert.deepEqual(readAisTrack(mmsi), track);
|
||||
|
||||
// Empty or omitted static fields must not erase known metadata, including
|
||||
// the cached values used when the next actual position arrives.
|
||||
assert.equal(sendStatic(scenario.empty), true);
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||
assert.deepEqual(readAisTrack(mmsi), track);
|
||||
if (scenario.type === 'StaticDataReport') {
|
||||
sendStatic({
|
||||
...classBStatic('A', ''),
|
||||
ReportA: { Valid: false, Name: 'INVALID NAME' },
|
||||
ReportB: { ...classBStatic('B', 99).ReportB, Valid: false },
|
||||
});
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||
}
|
||||
now = start + 60_000 + AISSTREAM_STALE_MS + 1;
|
||||
assert.equal(
|
||||
rowFor(mmsi),
|
||||
undefined,
|
||||
'static traffic must not renew position freshness',
|
||||
);
|
||||
position(53.42879, { Sog: 0, Cog: 0, TrueHeading: 0 });
|
||||
const next = rowFor(mmsi);
|
||||
for (const [key, value] of Object.entries(scenario.expected))
|
||||
assert.equal(next[key], value, key);
|
||||
assert.equal(next.speed, 0);
|
||||
assert.equal(next.course, 0);
|
||||
assert.equal(next.heading, 0);
|
||||
assert.equal(next.last_position_epoch, Math.floor(now / 1000));
|
||||
assert.equal(readAisTrack(mmsi).length, 3);
|
||||
});
|
||||
}
|
||||
|
||||
test('partial static data keeps newer live metadata ahead of the old static cache', () => {
|
||||
const mmsi = '244690200';
|
||||
const staticReport = (body) => ({
|
||||
MessageType: 'ShipStaticData',
|
||||
Message: { ShipStaticData: { UserID: Number(mmsi), ...body } },
|
||||
MetaData: { MMSI: mmsi },
|
||||
});
|
||||
ingestAisStreamEnvelope(staticReport({ Name: 'OLD VESSEL', Type: 70 }));
|
||||
const extended = positionReport(mmsi, {});
|
||||
extended.MessageType = 'ExtendedClassBPositionReport';
|
||||
extended.Message = {
|
||||
ExtendedClassBPositionReport: {
|
||||
UserID: Number(mmsi),
|
||||
Type: 80,
|
||||
Sog: 12,
|
||||
Cog: 86,
|
||||
},
|
||||
};
|
||||
extended.MetaData.ShipName = 'NEW VESSEL';
|
||||
ingestAisStreamEnvelope(extended);
|
||||
const before = rowFor(mmsi);
|
||||
ingestAisStreamEnvelope(staticReport({ Destination: 'HAMBURG' }));
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, destination: 'HAMBURG' });
|
||||
ingestAisStreamEnvelope(positionReport(mmsi, { Sog: 12, Cog: 86 }));
|
||||
assert.equal(rowFor(mmsi).name, 'NEW VESSEL');
|
||||
assert.equal(rowFor(mmsi).type, '80');
|
||||
assert.equal(rowFor(mmsi).destination, 'HAMBURG');
|
||||
});
|
||||
@@ -115,7 +115,8 @@ test('status retains official clocks, knots, source links and finite current pos
|
||||
assert.equal(storm.issuedAt, TIME);
|
||||
assert.equal(storm.positionAt, TIME);
|
||||
assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 });
|
||||
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 12 });
|
||||
// NHC publishes movementSpeed in mph: 12 mph is 10 kt.
|
||||
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 10 });
|
||||
assert.equal(storm.windKt, 25);
|
||||
assert.equal(storm.pressureHpa, 1006);
|
||||
assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
|
||||
|
||||
+33
-10
@@ -81,21 +81,44 @@ export function parseAcquisitionMs(date, time, cache = new Map()) {
|
||||
const cached = cache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
// Same calendar as the CSV gate. Date.UTC would otherwise roll 30 February
|
||||
// into March, and the fire's age would describe a detection that never happened.
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
cache.set(key, 0);
|
||||
return 0;
|
||||
}
|
||||
const timeText = String(time ?? '').trim();
|
||||
if (!/^\d{1,4}$/.test(timeText)) {
|
||||
cache.set(key, 0);
|
||||
return 0;
|
||||
}
|
||||
const hhmm = timeText.padStart(4, '0');
|
||||
const year = Number(date.slice(0, 4));
|
||||
const month = Number(date.slice(5, 7));
|
||||
const day = Number(date.slice(8, 10));
|
||||
const hhmm = String(time ?? '0000').padStart(4, '0');
|
||||
const hours = Number(hhmm.slice(0, 2));
|
||||
const minutes = Number(hhmm.slice(2, 4));
|
||||
const valid =
|
||||
Number.isFinite(year) &&
|
||||
Number.isFinite(month) &&
|
||||
Number.isFinite(day) &&
|
||||
Number.isFinite(hours) &&
|
||||
Number.isFinite(minutes);
|
||||
const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
|
||||
cache.set(key, ms);
|
||||
return ms;
|
||||
if (
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
day > 31 ||
|
||||
hours > 23 ||
|
||||
minutes > 59
|
||||
) {
|
||||
cache.set(key, 0);
|
||||
return 0;
|
||||
}
|
||||
const ms = Date.UTC(year, month - 1, day, hours, minutes);
|
||||
const parsed = new Date(ms);
|
||||
const result =
|
||||
parsed.getUTCFullYear() === year &&
|
||||
parsed.getUTCMonth() === month - 1 &&
|
||||
parsed.getUTCDate() === day
|
||||
? ms
|
||||
: 0;
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,12 +63,39 @@ test('unpadded acq_time parses in the adapter path ("45" = 00:45Z)', () => {
|
||||
|
||||
test('parseAcquisitionMs: memo cache is honored, invalid input → 0', () => {
|
||||
const cache = new Map();
|
||||
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
|
||||
assert.equal(
|
||||
parseAcquisitionMs('2026-07-16', '1006', cache),
|
||||
Date.UTC(2026, 6, 16, 10, 6),
|
||||
);
|
||||
assert.equal(cache.size, 1);
|
||||
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
|
||||
assert.equal(
|
||||
parseAcquisitionMs('2026-07-16', '1006', cache),
|
||||
Date.UTC(2026, 6, 16, 10, 6),
|
||||
);
|
||||
assert.equal(parseAcquisitionMs(undefined, '1006', cache), 0);
|
||||
});
|
||||
|
||||
test('impossible acquisition dates stay untimed instead of rolling forward', () => {
|
||||
for (const [date, time] of [
|
||||
['2026-02-30', '1200'],
|
||||
['2026-04-31', '1200'],
|
||||
['2026-13-01', '1200'],
|
||||
['2026-07-16', '2400'],
|
||||
['2026-07-16', '1260'],
|
||||
]) {
|
||||
assert.equal(parseAcquisitionMs(date, time), 0, `${date}:${time}`);
|
||||
const [fire] = adaptFirmsRecords([
|
||||
proxyRecord({ acqDate: date, acqTime: time }),
|
||||
]);
|
||||
assert.equal(fire.acqMs, 0, `${date}:${time}`);
|
||||
assert.equal(fire.lat, 38.99488);
|
||||
}
|
||||
const [leap] = adaptFirmsRecords([
|
||||
proxyRecord({ acqDate: '2024-02-29', acqTime: '45' }),
|
||||
]);
|
||||
assert.equal(leap.acqMs, Date.UTC(2024, 1, 29, 0, 45));
|
||||
});
|
||||
|
||||
test('daynight "D" → night false', () => {
|
||||
const [fire] = adaptFirmsRecords([proxyRecord({ daynight: 'D' })]);
|
||||
assert.equal(fire.night, false);
|
||||
@@ -89,11 +116,16 @@ test('records with non-finite lat/lon are skipped; index stays sequential', () =
|
||||
proxyRecord({ lat: 40.1 }),
|
||||
]);
|
||||
assert.equal(fires.length, 2);
|
||||
assert.deepEqual(fires.map((f) => f.index), [0, 1]);
|
||||
assert.deepEqual(
|
||||
fires.map((f) => f.index),
|
||||
[0, 1],
|
||||
);
|
||||
});
|
||||
|
||||
test('non-finite frp/brightness → 0; empty input → []', () => {
|
||||
const [fire] = adaptFirmsRecords([proxyRecord({ frp: 'n/a', brightness: undefined })]);
|
||||
const [fire] = adaptFirmsRecords([
|
||||
proxyRecord({ frp: 'n/a', brightness: undefined }),
|
||||
]);
|
||||
assert.equal(fire.frp, 0);
|
||||
assert.equal(fire.brightness, 0);
|
||||
assert.deepEqual(adaptFirmsRecords([]), []);
|
||||
|
||||
@@ -53,6 +53,21 @@ test('buildFireCard: missing acquisition time omits the age segment', () => {
|
||||
assert.equal(card.details[0], 'high · N20');
|
||||
});
|
||||
|
||||
test('buildFireCard: power that rounds to 10 MW drops the decimal', () => {
|
||||
const crossed = buildFireCard(
|
||||
{ fire: fire({ frp: 9.96 }), position: {} },
|
||||
NOW,
|
||||
);
|
||||
assert.equal(crossed.title, '▲ 10 MW');
|
||||
const below = buildFireCard(
|
||||
{ fire: fire({ frp: 9.94 }), position: {} },
|
||||
NOW,
|
||||
);
|
||||
assert.equal(below.title, '▲ 9.9 MW');
|
||||
const selected = buildSelectedFireCard(fire({ frp: 9.96 }), NOW);
|
||||
assert.equal(selected.title, 'FIRE · 10 MW');
|
||||
});
|
||||
|
||||
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => {
|
||||
const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW);
|
||||
assert.match(card.details[0], /SNPP$/);
|
||||
|
||||
@@ -39,6 +39,17 @@ export function accentForSeverity(stopName) {
|
||||
return ACCENT_RGB[stopName] || ACCENT_RGB.yellow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire radiative power as shown on a card: "9.9" below 10 MW, "10" once
|
||||
* one-decimal rounding reaches 10. "10.0" would sit beside a whole "10".
|
||||
* @param {number} frp Megawatts.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatFrpMw(frp) {
|
||||
const digits = Number(Number(frp).toFixed(1)) >= 10 ? 0 : 1;
|
||||
return Number(frp).toFixed(digits);
|
||||
}
|
||||
|
||||
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
|
||||
export function satelliteShortName(satellite) {
|
||||
const s = String(satellite || '')
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
FIRMS_AMBIENT_COHORT_LIMIT,
|
||||
FIRMS_OVERLAY_SOURCE_ID,
|
||||
satelliteShortName,
|
||||
formatFrpMw,
|
||||
} from './firmsLabels.js';
|
||||
|
||||
test('FIRMS formatting helpers retain the shipped severity palette', () => {
|
||||
@@ -15,6 +16,14 @@ test('FIRMS formatting helpers retain the shipped severity palette', () => {
|
||||
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow'));
|
||||
});
|
||||
|
||||
test('FIRMS power that rounds to 10 MW drops the decimal', () => {
|
||||
assert.equal(formatFrpMw(9.96), '10');
|
||||
assert.equal(formatFrpMw(9.94), '9.9');
|
||||
assert.equal(formatFrpMw(0.8), '0.8');
|
||||
assert.equal(formatFrpMw(1520.27), '1520');
|
||||
assert.equal(formatFrpMw(210.4), '210');
|
||||
});
|
||||
|
||||
test('FIRMS satellite names retain the three VIIRS abbreviations', () => {
|
||||
assert.equal(satelliteShortName('N20'), 'N20');
|
||||
assert.equal(satelliteShortName('N21'), 'N21');
|
||||
|
||||
+95
-18
@@ -1040,26 +1040,103 @@ export function detachImportedTime(dataSource) {
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
/** Repair only the first KML opening tag, scanning its delimiter once. */
|
||||
export function repairKmlRootNamespaces(value, what = 'KML') {
|
||||
const text = String(value);
|
||||
// Skip XML comments and processing instructions without treating their
|
||||
// literal '<kml' text as an opening root tag.
|
||||
let start = 0;
|
||||
for (;;) {
|
||||
start = text.indexOf('<', start);
|
||||
if (start < 0) return text;
|
||||
if (text.slice(start, start + 9).toUpperCase() === '<!DOCTYPE') {
|
||||
let quote = null;
|
||||
let subsetDepth = 0;
|
||||
let end = start + 9;
|
||||
for (; end < text.length; end++) {
|
||||
const char = text[end];
|
||||
if (quote) {
|
||||
if (char === quote) quote = null;
|
||||
} else if (char === '"' || char === "'") quote = char;
|
||||
else if (char === '<' && text.startsWith('<!--', end)) {
|
||||
// Comments and processing instructions inside the internal subset
|
||||
// carry no declaration syntax; jump past them as a whole.
|
||||
const skip = text.indexOf('-->', end + 4);
|
||||
if (skip < 0) return text;
|
||||
end = skip + 2;
|
||||
} else if (char === '<' && text.startsWith('<?', end)) {
|
||||
const skip = text.indexOf('?>', end + 2);
|
||||
if (skip < 0) return text;
|
||||
end = skip + 1;
|
||||
} else if (char === '[') subsetDepth++;
|
||||
else if (char === ']') subsetDepth--;
|
||||
else if (char === '>' && subsetDepth === 0) break;
|
||||
}
|
||||
if (end === text.length) return text;
|
||||
start = end + 1;
|
||||
continue;
|
||||
}
|
||||
const close = text.startsWith('<!--', start)
|
||||
? '-->'
|
||||
: text.startsWith('<?', start)
|
||||
? '?>'
|
||||
: null;
|
||||
if (!close) break;
|
||||
const end = text.indexOf(close, start + 2);
|
||||
if (end < 0) return text; // The XML parser reports an unterminated preamble.
|
||||
start = end + close.length;
|
||||
}
|
||||
const match = /^<kml\b/i.exec(text.slice(start, start + 5));
|
||||
if (!match) return text;
|
||||
let quote = null;
|
||||
let end = -1;
|
||||
for (let index = start + match[0].length; index < text.length; index++) {
|
||||
const char = text[index];
|
||||
if (char === '<') break; // Raw '<' is invalid even inside an XML attribute.
|
||||
if (quote) {
|
||||
if (char === quote) quote = null;
|
||||
} else if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
} else if (char === '>') {
|
||||
end = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (end < 0)
|
||||
throw new GeoImportError(
|
||||
`That ${what} file is not valid XML.`,
|
||||
msg('That {format} file is not valid XML.'),
|
||||
{ format: what },
|
||||
);
|
||||
// Walk the root's attributes in order so quoted values are never read as
|
||||
// declarations. Anything the walk cannot tokenize is kept verbatim for the
|
||||
// XML parser to judge.
|
||||
const seen = new Set();
|
||||
const attribute = /(\s+)([^\s=/>"']+)\s*=\s*("[^"]*"|'[^']*')/y;
|
||||
let position = start + match[0].length;
|
||||
let root = text.slice(start, position);
|
||||
for (;;) {
|
||||
attribute.lastIndex = position;
|
||||
const found = attribute.exec(text);
|
||||
if (!found || attribute.lastIndex > end) break;
|
||||
position = attribute.lastIndex;
|
||||
const prefix = /^xmlns:([\w.-]+)$/.exec(found[2])?.[1];
|
||||
if (prefix && seen.has(prefix)) continue;
|
||||
if (prefix) seen.add(prefix);
|
||||
root += found[0];
|
||||
}
|
||||
root += text.slice(position, end);
|
||||
if (!seen.has('xsi') && /[<\s]xsi:/.test(text))
|
||||
root = root.replace(
|
||||
/(\/?)>$/,
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"$1>',
|
||||
);
|
||||
return text.slice(0, start) + root + text.slice(end);
|
||||
}
|
||||
|
||||
function parseXml(text, what) {
|
||||
// Match the namespace repairs Cesium applies to ordinary exported KML.
|
||||
if (what !== 'GPX')
|
||||
text = String(text).replace(/<kml\b[^>]*>/i, (root) => {
|
||||
const seen = new Set();
|
||||
root = root.replace(
|
||||
/\s+xmlns:([\w.-]+)\s*=\s*(["'])[^"']*\2/g,
|
||||
(declaration, prefix) => {
|
||||
if (seen.has(prefix)) return '';
|
||||
seen.add(prefix);
|
||||
return declaration;
|
||||
},
|
||||
);
|
||||
if (!seen.has('xsi') && /[<\s]xsi:/.test(text))
|
||||
root = root.replace(
|
||||
/>$/,
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
|
||||
);
|
||||
return root;
|
||||
});
|
||||
if (what !== 'GPX') text = repairKmlRootNamespaces(text, what);
|
||||
const doc = new DOMParser().parseFromString(
|
||||
String(text).replace(/^/, ''),
|
||||
'application/xml',
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { deflateRawSync } from 'node:zlib';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
GeoImportError,
|
||||
repairKmlRootNamespaces,
|
||||
loadGeoFile,
|
||||
MAX_IMPORT_POSITIONS,
|
||||
MAX_IMPORT_ENTITIES,
|
||||
@@ -1171,3 +1173,92 @@ test('a dated import exposes no clock and draws for all time', () => {
|
||||
assert.equal(recorded.getValue(Cesium.JulianDate.now()).length, 2);
|
||||
assert.ok(track.polyline.clampToGround.getValue());
|
||||
});
|
||||
|
||||
test('KML root namespace repair respects quoted delimiters and preserves the document tail', () => {
|
||||
const text =
|
||||
'<KML note="a > b" xmlns:x="first" xmlns:x="second"><Placemark xsi:type="test"/></KML>';
|
||||
const result = repairKmlRootNamespaces(text);
|
||||
assert.match(result, /note="a > b"/);
|
||||
assert.match(result, /xmlns:x="first"/);
|
||||
assert.doesNotMatch(result, /second/);
|
||||
assert.match(
|
||||
result,
|
||||
/xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance"/,
|
||||
);
|
||||
assert.ok(result.endsWith('<Placemark xsi:type="test"/></KML>'));
|
||||
assert.match(
|
||||
repairKmlRootNamespaces('<kml xsi:type="test"/>'),
|
||||
/XMLSchema-instance"\/>$/,
|
||||
);
|
||||
for (const malformed of ['<kml ', '<kml name="unterminated>', '<kml <kml >'])
|
||||
assert.throws(() => repairKmlRootNamespaces(malformed), GeoImportError);
|
||||
});
|
||||
|
||||
test('repeated incomplete KML roots finish within an isolated process deadline', () => {
|
||||
const source = `import { repairKmlRootNamespaces } from ${JSON.stringify(new URL('./geoFileImport.js', import.meta.url).href)};
|
||||
try { repairKmlRootNamespaces('<kml '.repeat(1000000)); process.exit(1); }
|
||||
catch (error) { if (error.name !== 'GeoImportError') throw error; }
|
||||
try { repairKmlRootNamespaces('<!DOCTYPE kml [' + ${JSON.stringify("<!-- ' --><?pi ' ?>")}.repeat(100000) + ']><kml '); process.exit(1); }
|
||||
catch (error) { if (error.name !== 'GeoImportError') throw error; }
|
||||
const wide = '<kml' + ${JSON.stringify(` xmlns:gx="a" note='xmlns:gx="b"'`)}.repeat(20000) + '/>';
|
||||
if (repairKmlRootNamespaces(wide).split('xmlns:gx="a"').length !== 2) process.exit(1);`;
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
['--input-type=module', '-e', source],
|
||||
{ timeout: 5000, encoding: 'utf8' },
|
||||
);
|
||||
assert.ifError(child.error);
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
});
|
||||
|
||||
test('KML root repair ignores incomplete tag text inside preamble comments', () => {
|
||||
const input =
|
||||
'<?xml version="1.0"?><!-- example <kml incomplete -->\n<kml xmlns:gx="a" xmlns:gx="b"><Document/></kml>';
|
||||
assert.equal(
|
||||
repairKmlRootNamespaces(input),
|
||||
input.replace(' xmlns:gx="b"', ''),
|
||||
);
|
||||
});
|
||||
|
||||
test('KML namespace repair follows a doctype with a quoted root-like entity', () => {
|
||||
const input =
|
||||
'<!DOCTYPE kml [<!ENTITY example "<kml incomplete >">]><kml xmlns:gx="a" xmlns:gx="b"/>';
|
||||
assert.equal(
|
||||
repairKmlRootNamespaces(input),
|
||||
input.replace(' xmlns:gx="b"', ''),
|
||||
);
|
||||
});
|
||||
|
||||
test('KML namespace repair skips doctype comments and processing instructions whole', () => {
|
||||
for (const prolog of [
|
||||
"<!DOCTYPE kml [<!-- don't count this quote -->]>",
|
||||
'<!DOCTYPE kml [<!-- a ] or > here --><!ENTITY x "y">]>',
|
||||
"<!DOCTYPE kml [<?note it's ] > fine?>]>",
|
||||
'<!DOCTYPE kml [<!ENTITY x "<!-- \' -->">]>',
|
||||
]) {
|
||||
const input = `${prolog}<kml xmlns:gx="a" xmlns:gx="b"><Placemark xsi:type="t"/></kml>`;
|
||||
assert.equal(
|
||||
repairKmlRootNamespaces(input),
|
||||
input.replace(
|
||||
' xmlns:gx="b">',
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
|
||||
),
|
||||
prolog,
|
||||
);
|
||||
}
|
||||
const unterminated = '<!DOCTYPE kml [<!-- open <kml xmlns:gx="a">';
|
||||
assert.equal(repairKmlRootNamespaces(unterminated), unterminated);
|
||||
});
|
||||
|
||||
test('KML namespace repair ignores declaration text inside quoted attribute values', () => {
|
||||
const input =
|
||||
'<kml note=\'a > b xmlns:gx="fake"\' xmlns:gx="http://www.google.com/kml/ext/2.2" xmlns:gx="dup"><gx:Track/></kml>';
|
||||
const result = repairKmlRootNamespaces(input);
|
||||
assert.equal(result, input.replace(' xmlns:gx="dup"', ''));
|
||||
assert.match(result, /\sxmlns:gx="http:\/\/www.google.com\/kml\/ext\/2.2"/);
|
||||
const xsi = '<kml note="xmlns:xsi=\'x\'"><Placemark xsi:type="t"/></kml>';
|
||||
assert.match(
|
||||
repairKmlRootNamespaces(xsi),
|
||||
/note="xmlns:xsi='x'" xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance">/,
|
||||
);
|
||||
});
|
||||
@@ -134,7 +134,10 @@ test('every scene click handler consults ownership before it picks', () => {
|
||||
'src/layers/submarineCables/interaction.js',
|
||||
'viewer.scene.pick(click.position)',
|
||||
],
|
||||
['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'],
|
||||
[
|
||||
'src/layers/vessels/selection.js',
|
||||
'pickContact(viewer.scene, click.position)',
|
||||
],
|
||||
];
|
||||
for (const [file, firstPick] of guarded) {
|
||||
const source = read(file);
|
||||
|
||||
+60
-17
@@ -72,13 +72,20 @@ function refreshFailureFromStats(stats, label) {
|
||||
/**
|
||||
* LayerLifecycle — Manages registration, toggling, and update loops
|
||||
* for real-time data overlays on the CesiumJS globe.
|
||||
* Hosts may inject a visibilityTarget with hidden/visibilityState and event
|
||||
* listener methods. Without one, periodic work retains its headless behavior.
|
||||
*/
|
||||
export class LayerLifecycle {
|
||||
constructor(
|
||||
viewer,
|
||||
{ allowQaRegistration = false, getSourceAvailability } = {},
|
||||
{
|
||||
allowQaRegistration = false,
|
||||
getSourceAvailability,
|
||||
visibilityTarget = null,
|
||||
} = {},
|
||||
) {
|
||||
this._catalogSourceAvailability = getSourceAvailability;
|
||||
this._visibilityTarget = visibilityTarget;
|
||||
this.viewer = viewer;
|
||||
this._activityListeners = new Set();
|
||||
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
|
||||
@@ -528,6 +535,13 @@ export class LayerLifecycle {
|
||||
}
|
||||
}
|
||||
|
||||
_stopUpdateLoop(entry) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
entry.removeVisibilityListener?.();
|
||||
entry.removeVisibilityListener = null;
|
||||
}
|
||||
|
||||
_armUpdateLoop(layerId, entry) {
|
||||
const configuredRefreshInterval = Number(entry.module.refreshInterval);
|
||||
const updateInterval = Number(entry.module.updateInterval);
|
||||
@@ -538,9 +552,45 @@ export class LayerLifecycle {
|
||||
? updateInterval
|
||||
: 0;
|
||||
if (refreshInterval > 0) {
|
||||
entry.intervalId = setInterval(() => {
|
||||
const visibilityDocument = this._visibilityTarget;
|
||||
let intervalId = null;
|
||||
let dueAt = 0;
|
||||
const canPoll = () =>
|
||||
!visibilityDocument?.hidden &&
|
||||
visibilityDocument?.visibilityState !== 'hidden' &&
|
||||
entry.enabled &&
|
||||
!entry.destroying &&
|
||||
!entry.refreshing &&
|
||||
entry.intervalId === intervalId;
|
||||
// dueAt and the interval phase share one origin, so every interval tick
|
||||
// is a due poll. A visibility catch-up re-arms the interval at the new
|
||||
// deadline; otherwise the old phase would skip its next tick.
|
||||
const schedule = () => {
|
||||
clearInterval(intervalId);
|
||||
dueAt = Date.now() + refreshInterval;
|
||||
intervalId = setInterval(onInterval, refreshInterval);
|
||||
entry.intervalId = intervalId;
|
||||
};
|
||||
const onInterval = () => {
|
||||
if (!canPoll()) return;
|
||||
dueAt = Date.now() + refreshInterval;
|
||||
void this._runPeriodicUpdate(layerId, entry);
|
||||
}, refreshInterval);
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (!canPoll() || Date.now() < dueAt) return;
|
||||
schedule();
|
||||
void this._runPeriodicUpdate(layerId, entry);
|
||||
};
|
||||
schedule();
|
||||
visibilityDocument?.addEventListener?.(
|
||||
'visibilitychange',
|
||||
onVisibilityChange,
|
||||
);
|
||||
entry.removeVisibilityListener = () =>
|
||||
visibilityDocument?.removeEventListener?.(
|
||||
'visibilitychange',
|
||||
onVisibilityChange,
|
||||
);
|
||||
} else if (updateInterval === 0) {
|
||||
entry.intervalId = setInterval(() => {
|
||||
if (!entry.enabled) return;
|
||||
@@ -772,8 +822,7 @@ export class LayerLifecycle {
|
||||
entry.lifecycleUncertain = !compensated && !cleanupConfirmed;
|
||||
settleLifecycle();
|
||||
if (!entry.enabled && entry.intervalId) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
this._publishActivity({ type: 'status' });
|
||||
if (!compensated) {
|
||||
@@ -833,8 +882,7 @@ export class LayerLifecycle {
|
||||
}
|
||||
if (signal?.aborted) return finishCancelledDisable('disable');
|
||||
if (entry.intervalId) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
entry.enabled = false;
|
||||
entry.lifecycleUncertain = false;
|
||||
@@ -845,8 +893,7 @@ export class LayerLifecycle {
|
||||
let abortCleanup = null;
|
||||
const cancelEnable = () => {
|
||||
if (entry.intervalId) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
// Disable immediately so modules with their own AbortController (Radio)
|
||||
// cancel pending update work at the same turn boundary. A second
|
||||
@@ -876,8 +923,7 @@ export class LayerLifecycle {
|
||||
// a later abort cannot revoke a successful retry.
|
||||
signal?.removeEventListener('abort', cancelEnable);
|
||||
if (entry.intervalId) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
await abortCleanup;
|
||||
let cleanupConfirmed = false;
|
||||
@@ -918,8 +964,7 @@ export class LayerLifecycle {
|
||||
};
|
||||
const finishFailedEnable = async (phase, error) => {
|
||||
if (entry.intervalId) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
let cleanupConfirmed = false;
|
||||
try {
|
||||
@@ -1029,8 +1074,7 @@ export class LayerLifecycle {
|
||||
// Always clear any stale interval before assigning a new one, so we never
|
||||
// orphan a running timer and end up double-polling.
|
||||
if (entry.intervalId) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
|
||||
// Manager-owned periodic refresh work has one normalized loading/error
|
||||
@@ -2158,8 +2202,7 @@ export class LayerLifecycle {
|
||||
return false;
|
||||
}
|
||||
if (entry.intervalId) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
entry.enabled = false;
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ export { layerFeedState } from '../ui/layers.js';
|
||||
/** Compatibility facade for callers that construct a manager with its panel. */
|
||||
export class DataLayerManager extends LayerLifecycle {
|
||||
constructor(viewer, options) {
|
||||
super(viewer, options);
|
||||
super(viewer, { visibilityTarget: globalThis.document, ...options });
|
||||
this._presentation = new LayerPresentation(this);
|
||||
}
|
||||
buildTogglePanel(container) {
|
||||
|
||||
@@ -3492,3 +3492,92 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async
|
||||
lifecycle.refreshLayerStats();
|
||||
assert.deepEqual(changes, ['status']);
|
||||
});
|
||||
|
||||
test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => {
|
||||
const listeners = new Set();
|
||||
const doc = {
|
||||
hidden: false,
|
||||
addEventListener: (_name, fn) => listeners.add(fn),
|
||||
removeEventListener: (_name, fn) => listeners.delete(fn),
|
||||
};
|
||||
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
|
||||
const { LayerLifecycle } = await import('./lifecycle.js');
|
||||
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
|
||||
const { module, calls } = makeSlowLayer('hidden-poll');
|
||||
manager.register(module);
|
||||
await manager.setEnabled('hidden-poll', true);
|
||||
assert.equal(calls.update, 1);
|
||||
doc.hidden = true;
|
||||
t.mock.timers.tick(10000);
|
||||
assert.equal(calls.update, 1);
|
||||
assert.equal(await manager.refreshLayer('hidden-poll'), true);
|
||||
assert.equal(calls.update, 2, 'explicit refresh remains allowed');
|
||||
doc.hidden = false;
|
||||
for (const fn of listeners) fn();
|
||||
for (const fn of listeners) fn();
|
||||
assert.equal(calls.update, 3);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await manager.destroyAll();
|
||||
assert.equal(listeners.size, 0);
|
||||
t.mock.timers.tick(10000);
|
||||
assert.equal(calls.update, 3);
|
||||
});
|
||||
|
||||
async function resumeBetweenTicks(t, { failResume = false } = {}) {
|
||||
const listeners = new Set();
|
||||
const doc = {
|
||||
hidden: false,
|
||||
addEventListener: (_name, fn) => listeners.add(fn),
|
||||
removeEventListener: (_name, fn) => listeners.delete(fn),
|
||||
};
|
||||
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
|
||||
const { LayerLifecycle } = await import('./lifecycle.js');
|
||||
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
|
||||
const { module, calls } = makeSlowLayer('resume-poll');
|
||||
const updates = [];
|
||||
module.update = async () => {
|
||||
calls.update++;
|
||||
updates.push(Date.now());
|
||||
await Promise.resolve();
|
||||
if (failResume && updates.length === 2) throw new Error('resume failed');
|
||||
};
|
||||
manager.register(module);
|
||||
await manager.setEnabled('resume-poll', true);
|
||||
assert.deepEqual(updates, [1000]);
|
||||
doc.hidden = true;
|
||||
t.mock.timers.tick(1000); // 2000: due tick skipped while hidden
|
||||
t.mock.timers.tick(500); // 2500: return between ticks
|
||||
doc.hidden = false;
|
||||
for (const fn of listeners) fn();
|
||||
assert.deepEqual(updates, [1000, 2500], 'overdue poll runs once on return');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
const managerRefreshError = manager.layers.get('resume-poll').managerRefreshError;
|
||||
if (failResume) assert.equal(managerRefreshError, 'resume failed');
|
||||
else assert.equal(managerRefreshError, null);
|
||||
t.mock.timers.tick(999); // 3499: the old 3000 phase must not fire
|
||||
assert.deepEqual(updates, [1000, 2500]);
|
||||
t.mock.timers.tick(1); // 3500: one interval after the resume poll
|
||||
assert.deepEqual(updates, [1000, 2500, 3500]);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(updates, [1000, 2500, 3500, 4500], 'new phase keeps one interval');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await manager.destroyAll();
|
||||
assert.equal(listeners.size, 0);
|
||||
t.mock.timers.tick(5000);
|
||||
assert.equal(updates.length, 4);
|
||||
}
|
||||
|
||||
test('periodic polls realign to the deadline after a between-ticks resume', async (t) => {
|
||||
await resumeBetweenTicks(t);
|
||||
});
|
||||
|
||||
test('periodic polls realign to the deadline after a failed resume refresh', async (t) => {
|
||||
await resumeBetweenTicks(t, { failResume: true });
|
||||
});
|
||||
@@ -26,7 +26,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
const memoryHit = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
memoryCache: new Map([[key, fresh]]),
|
||||
inFlight: new Map([[key, Promise.resolve(joined)]]),
|
||||
inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]),
|
||||
readDisk: async () => { diskReads += 1; return disk; },
|
||||
allowUpstream,
|
||||
now: 1000,
|
||||
@@ -40,7 +40,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
const inFlightHit = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
memoryCache: new Map([[key, { id: 'stale', status: 200, cachedAt: 0 }]]),
|
||||
inFlight: new Map([[key, Promise.resolve(joined)]]),
|
||||
inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]),
|
||||
readDisk: async () => { diskReads += 1; return disk; },
|
||||
allowUpstream,
|
||||
now: 1000,
|
||||
@@ -51,9 +51,10 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
assert.equal(diskReads, 0, 'in-flight join must short-circuit before disk');
|
||||
assert.equal(limiterCalls, 0, 'in-flight join must not consume limiter quota');
|
||||
|
||||
const diskMemory = new Map();
|
||||
const diskHit = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
memoryCache: new Map(),
|
||||
memoryCache: diskMemory,
|
||||
inFlight: new Map(),
|
||||
readDisk: async () => { diskReads += 1; return disk; },
|
||||
allowUpstream,
|
||||
@@ -62,6 +63,8 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
assert.equal(diskHit.payload, disk);
|
||||
assert.equal(diskReads, 1);
|
||||
assert.equal(limiterCalls, 0, 'disk hit must not consume limiter quota');
|
||||
const publishedDisk = await resolveOverpassPreflight({ cacheKey: key, memoryCache: diskMemory, inFlight: new Map(), now: 1000, readDisk: async () => { throw new Error('disk result was not published'); }, allowUpstream });
|
||||
assert.equal(publishedDisk.source, 'HIT');
|
||||
|
||||
const upstreamMiss = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
@@ -214,3 +217,66 @@ function pointSegDistDeg(p, a, b) {
|
||||
const t = c1 / c2;
|
||||
return Math.hypot(wx - t * vx, wy - t * vy);
|
||||
}
|
||||
|
||||
|
||||
test('cold disk lookup is single-flight through upstream publication and quota admission', async () => {
|
||||
const inFlight = new Map();
|
||||
let release;
|
||||
const disk = new Promise((resolve) => { release = resolve; });
|
||||
let reads = 0, admissions = 0, upstreams = 0;
|
||||
const payload = { status: 200, body: '{"elements":[]}' };
|
||||
const options = {
|
||||
cacheKey: 'cold', memoryCache: new Map(), inFlight,
|
||||
readDisk: () => { reads++; return disk; },
|
||||
allowUpstream: () => { admissions++; return true; },
|
||||
loadUpstream: async () => { upstreams++; return payload; },
|
||||
};
|
||||
const pending = Array.from({ length: 4 }, () => resolveOverpassPreflight(options));
|
||||
await Promise.resolve();
|
||||
assert.equal(inFlight.size, 1);
|
||||
release(null);
|
||||
const results = await Promise.all(pending);
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(admissions, 1);
|
||||
assert.equal(upstreams, 1);
|
||||
assert.ok(results.every((result) => result.payload === payload));
|
||||
assert.deepEqual(results.map((result) => result.source), ['UPSTREAM', 'INFLIGHT', 'INFLIGHT', 'INFLIGHT']);
|
||||
assert.equal(inFlight.size, 0);
|
||||
const failed = { ...options, readDisk: async () => { throw new Error('disk failed'); } };
|
||||
const rejected = await Promise.allSettled([resolveOverpassPreflight(failed), resolveOverpassPreflight(failed)]);
|
||||
assert.ok(rejected.every((result) => result.status === 'rejected'));
|
||||
assert.equal(inFlight.size, 0);
|
||||
await resolveOverpassPreflight({ ...options, readDisk: async () => null });
|
||||
assert.equal(upstreams, 2, 'a rejected reservation does not poison the next retry');
|
||||
});
|
||||
|
||||
test('a joined client retries admission with its own quota after another client is rate limited', async () => {
|
||||
const inFlight = new Map();
|
||||
let release;
|
||||
const disk = new Promise((resolve) => { release = resolve; });
|
||||
let reads = 0, upstreams = 0;
|
||||
const admissions = { a: 0, b: 0, c: 0 };
|
||||
const quota = { a: false, b: true, c: false };
|
||||
const payload = { status: 200, body: '{"elements":[]}' };
|
||||
const client = (name) => resolveOverpassPreflight({
|
||||
cacheKey: 'quota', memoryCache: new Map(), inFlight,
|
||||
readDisk: () => { reads++; return reads === 1 ? disk : null; },
|
||||
allowUpstream: () => { admissions[name]++; return quota[name]; },
|
||||
loadUpstream: async () => { upstreams++; return payload; },
|
||||
});
|
||||
// A (quota exhausted) owns the cold reservation; B (quota available) and
|
||||
// C (quota exhausted) join it while the disk lookup is pending.
|
||||
const pending = [client('a'), client('b'), client('c')];
|
||||
await Promise.resolve();
|
||||
assert.equal(inFlight.size, 1);
|
||||
release(null);
|
||||
const [a, b, c] = await Promise.all(pending);
|
||||
assert.equal(a.source, 'RATE_LIMITED', "A's own quota still rejects A");
|
||||
assert.equal(b.source, 'UPSTREAM', 'B is admitted on its own quota');
|
||||
assert.equal(b.payload, payload);
|
||||
assert.equal(c.source, 'INFLIGHT', "C coalesces onto B's admitted fetch");
|
||||
assert.equal(c.payload, payload);
|
||||
assert.deepEqual(admissions, { a: 1, b: 1, c: 0 });
|
||||
assert.equal(upstreams, 1);
|
||||
assert.equal(inFlight.size, 0);
|
||||
});
|
||||
@@ -11,6 +11,8 @@
|
||||
* that belong to someone else and leave them alone.
|
||||
*/
|
||||
|
||||
import { isSurfacePick } from './scenePick.js';
|
||||
|
||||
/** @type {Map<string, (pickedId: string) => boolean>} layerId -> ownership predicate */
|
||||
const _owners = new Map();
|
||||
|
||||
@@ -100,3 +102,36 @@ export function isOwnedByOtherLayer(layerId, pickedId) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a moving-contact click without letting map tiles hide a nearby touch
|
||||
* target. Exact picks always win; only a coarse primary pointer may expand a
|
||||
* surface miss. Every consumer uses the same ordered, bounded scene hits, so sibling layers
|
||||
* do not each choose their own contact under one tap.
|
||||
* Unowned non-surface objects remain barriers, not permission to pick through.
|
||||
*
|
||||
* @param {object} scene Cesium scene with pick and optional drillPick.
|
||||
* @param {{x: number, y: number}} position Screen position in CSS pixels.
|
||||
* @returns {object|null|undefined} Original pick or the first registered touch hit.
|
||||
*/
|
||||
export function pickContact(scene, position) {
|
||||
const picked = scene.pick(position);
|
||||
if (
|
||||
!globalThis.matchMedia?.('(pointer: coarse)').matches ||
|
||||
findPickOwner(resolvePickId(picked)) ||
|
||||
!isSurfacePick(picked) ||
|
||||
typeof scene.drillPick !== 'function'
|
||||
)
|
||||
return picked;
|
||||
let hits;
|
||||
try {
|
||||
hits = scene.drillPick(position, 8, 14, 14) || [];
|
||||
} catch {
|
||||
return picked;
|
||||
}
|
||||
for (const hit of hits.slice(0, 8)) {
|
||||
if (findPickOwner(resolvePickId(hit))) return hit;
|
||||
if (!isSurfacePick(hit)) break;
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
unregisterPickOwner,
|
||||
isOwnedByOtherLayer,
|
||||
resolvePickId,
|
||||
pickContact,
|
||||
} from './pickRegistry.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -97,3 +98,122 @@ test('ownership: unregister removes the predicate', () => {
|
||||
unregisterPickOwner('bikeshare');
|
||||
assert.equal(isOwnedByOtherLayer('flights', 'station:1'), false);
|
||||
});
|
||||
|
||||
function primaryPointer(t, coarse) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: (query) => ({ matches: query === '(pointer: coarse)' && coarse }),
|
||||
});
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
|
||||
else delete globalThis.matchMedia;
|
||||
});
|
||||
}
|
||||
|
||||
function contactOwners(t) {
|
||||
registerPickOwner('flights', (id) => id === 'civil');
|
||||
registerPickOwner('military', (id) => id === 'military');
|
||||
registerPickOwner('ais-live-vessels', (id) => id === '123456789');
|
||||
t.after(() => {
|
||||
for (const id of ['flights', 'military', 'ais-live-vessels'])
|
||||
unregisterPickOwner(id);
|
||||
});
|
||||
}
|
||||
|
||||
const mapTile = { primitive: { isCesium3DTileset: true } };
|
||||
|
||||
test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CSS pixels', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
const position = { x: 30, y: 50 };
|
||||
const contact = { primitive: { id: 'civil' } };
|
||||
let drills = 0;
|
||||
const scene = {
|
||||
pick(point) {
|
||||
assert.equal(point, position);
|
||||
return mapTile;
|
||||
},
|
||||
drillPick(...args) {
|
||||
drills++;
|
||||
assert.deepEqual(args, [position, 8, 14, 14]);
|
||||
return [mapTile, contact];
|
||||
},
|
||||
};
|
||||
assert.equal(pickContact(scene, position), contact);
|
||||
assert.equal(drills, 1);
|
||||
});
|
||||
|
||||
test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) {
|
||||
const scene = {
|
||||
pick: () => mapTile,
|
||||
drillPick: () => [mapTile, first, { id: 'civil' }],
|
||||
};
|
||||
assert.equal(pickContact(scene, { x: 1, y: 1 }), first);
|
||||
}
|
||||
});
|
||||
|
||||
test('touch contact pick does not drill through exact registered or unregistered interactive picks', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const exact of [
|
||||
{ id: 'military' },
|
||||
{ id: 'custom-annotation' },
|
||||
{ primitive: { id: 'custom-gizmo' } },
|
||||
{ primitive: { id: 'civil', isCesium3DTileset: true } },
|
||||
]) {
|
||||
const scene = {
|
||||
pick: () => exact,
|
||||
drillPick: () => assert.fail('must keep exact interactive hit'),
|
||||
};
|
||||
assert.equal(pickContact(scene, { x: 1, y: 1 }), exact);
|
||||
}
|
||||
});
|
||||
|
||||
test('touch expansion stops at a non-surface object and respects the drill bound', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const hits of [
|
||||
[mapTile, { id: 'custom-annotation' }, { id: 'civil' }],
|
||||
[...Array(8).fill(mapTile), { id: 'civil' }],
|
||||
[mapTile],
|
||||
]) {
|
||||
assert.equal(
|
||||
pickContact(
|
||||
{ pick: () => mapTile, drillPick: () => hits },
|
||||
{ x: 1, y: 1 },
|
||||
),
|
||||
mapTile,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('fine primary pointers keep the existing exact mouse pick without a drill', (t) => {
|
||||
primaryPointer(t, false);
|
||||
contactOwners(t);
|
||||
const scene = {
|
||||
pick: () => mapTile,
|
||||
drillPick: () => assert.fail('mouse must not expand'),
|
||||
};
|
||||
assert.equal(pickContact(scene, { x: 1, y: 1 }), mapTile);
|
||||
});
|
||||
|
||||
test('touch picking tolerates unavailable or failing drill support and preserves empty sky', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const drillPick of [
|
||||
undefined,
|
||||
() => {
|
||||
throw new Error('context lost');
|
||||
},
|
||||
() => [],
|
||||
]) {
|
||||
assert.equal(
|
||||
pickContact({ pick: () => undefined, drillPick }, { x: 1, y: 1 }),
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -13,8 +13,11 @@ function approximateDistanceM(latA, lonA, latB, lonB) {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
const latitudeScale = 111320;
|
||||
const longitudeScale = latitudeScale * Math.cos((latA * Math.PI) / 180);
|
||||
// The short way round, so places either side of 180° stay close.
|
||||
let dLon = lonB - lonA;
|
||||
dLon -= 360 * Math.round(dLon / 360);
|
||||
return Math.round(
|
||||
Math.hypot((latB - latA) * latitudeScale, (lonB - lonA) * longitudeScale),
|
||||
Math.hypot((latB - latA) * latitudeScale, dLon * longitudeScale),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -712,6 +712,58 @@ test('unusable directory responses preserve warm client state atomically', async
|
||||
assert.equal(getRadioUIState().stale, true);
|
||||
assert.equal(getRadioUIState().degraded, true);
|
||||
}
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement, { id: 'malformed' }], stale: false, degraded: false,
|
||||
acceptedGeneration: 2, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioUIState().stationCount, 1);
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 1);
|
||||
assert.equal(getRadioUIState().degraded, true);
|
||||
assert.equal(getRadioUIState().stale, false);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().stations[0].id, replacement.id);
|
||||
// Server-side structural rejections are combined with client rejections
|
||||
// after admission; the healthy generation is still accepted.
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement, { id: 'malformed' }], stale: false, degraded: false,
|
||||
acceptedGeneration: 3, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
rejectedStationCount: 2,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 3);
|
||||
assert.equal(getRadioUIState().degraded, true);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 3);
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement], stale: false, degraded: false,
|
||||
acceptedGeneration: 4, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
rejectedStationCount: 10_000,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 10_000);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
|
||||
for (const rejectedStationCount of [-1, 1.5, '2', null, 10_001]) {
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement], stale: false, degraded: false,
|
||||
acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
rejectedStationCount,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
|
||||
assert.equal(getRadioUIState().stale, true);
|
||||
}
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [{ id: 'malformed' }], stale: false, degraded: false,
|
||||
acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
|
||||
assert.equal(getRadioUIState().stale, true);
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 10_000);
|
||||
radioLayer.destroy();
|
||||
radioLayer.init(viewer);
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 0);
|
||||
assert.equal(getRadioUIState().degraded, false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
radioLayer.destroy();
|
||||
|
||||
@@ -11,7 +11,14 @@ import {
|
||||
publicRadioStation,
|
||||
publicRadioHttpsUrl,
|
||||
} from '../../vite.config.js';
|
||||
import { rankRadioStationsForRequest } from './radio.js';
|
||||
import {
|
||||
getRadioAcceptedCatalogSnapshot,
|
||||
getRadioUIState,
|
||||
radioLayer,
|
||||
rankRadioStationsForRequest,
|
||||
} from './radio.js';
|
||||
import { RADIO_REJECTED_STATION_COUNT_MAX as SERVER_REJECTED_MAX } from '../../server/providers/radio/constants.js';
|
||||
import { RADIO_REJECTED_STATION_COUNT_MAX as CLIENT_REJECTED_MAX } from '../layers/radio/policy.js';
|
||||
|
||||
const UUID = '12345678-1234-4234-8234-123456789abc';
|
||||
const publicLookup = async () => [{ address: '93.184.216.34', family: 4 }];
|
||||
@@ -533,3 +540,62 @@ test('a mirror answering an invalid reason phrase rejects instead of crashing',
|
||||
const crashed = mockMirrorStatus(t, 200, 'OK\x7f');
|
||||
await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError);
|
||||
});
|
||||
|
||||
|
||||
test('case expansion is capped after tag normalization in the portable and server station path', () => {
|
||||
const row = normalizeRadioBrowserStation(station({tags: 'İ'.repeat(80) + ', NEWS__RADIO ,news-radio'}));
|
||||
assert.equal(row.tags[0].length, 80);
|
||||
assert.equal(row.tags[0], 'i\u0307'.repeat(40));
|
||||
assert.deepEqual(row.tags.slice(1), ['news radio']);
|
||||
});
|
||||
|
||||
test('server structural rejections reach the client diagnostic without discarding a healthy generation', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const middleware = createProxy({
|
||||
now: () => Date.now(),
|
||||
fetchImpl: async (url) => {
|
||||
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
|
||||
const rows = healthyRowsForQuery(url);
|
||||
if (queryTag(url)) return responseJson(rows);
|
||||
// 400 valid broad rows, one row missing its required name, and one
|
||||
// schema-valid row that normalization filters by policy (not counted).
|
||||
const { name: _name, ...nameless } = station({ stationuuid: '50000000-0000-4000-8000-000000000001' });
|
||||
return responseJson([...rows, nameless, station({ stationuuid: '50000000-0000-4000-8000-000000000002', lastcheckok: 0 })]);
|
||||
},
|
||||
});
|
||||
const response = await invoke(middleware, '/stations');
|
||||
assert.equal(response.status, 200);
|
||||
const body = JSON.parse(response.body);
|
||||
assert.equal(body.degraded, false);
|
||||
assert.equal(body.degradedReason, null);
|
||||
assert.equal(body.acceptedGeneration, 1);
|
||||
assert.equal(body.rejectedStationCount, 1);
|
||||
assert.equal(SERVER_REJECTED_MAX, CLIENT_REJECTED_MAX);
|
||||
|
||||
const viewer = {
|
||||
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
|
||||
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
|
||||
dataSources: { add() {}, remove() {} },
|
||||
entities: { add(entity) { return entity; }, remove() {} },
|
||||
};
|
||||
radioLayer.destroy();
|
||||
try {
|
||||
globalThis.fetch = async () => new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
radioLayer.init(viewer);
|
||||
radioLayer.enable();
|
||||
await radioLayer.update();
|
||||
const state = getRadioUIState();
|
||||
assert.equal(state.stationCount, body.stations.length);
|
||||
assert.equal(state.rejectedStationCount, 1);
|
||||
assert.equal(state.degraded, true);
|
||||
assert.equal(state.stale, false);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 1);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().instance, body.catalogInstance);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
radioLayer.destroy();
|
||||
}
|
||||
});
|
||||
@@ -988,6 +988,16 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
]);
|
||||
// Cyber deliberately adds one layout; first-run missions still change no tools.
|
||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||
const cameraMotion = legacyTools.find((tool) => tool.name === 'move_camera')
|
||||
.parameters.properties.motion;
|
||||
assert.deepEqual(cameraMotion.enum, [
|
||||
'orbit',
|
||||
'pan',
|
||||
'tilt',
|
||||
'rotate',
|
||||
'rotate_globe',
|
||||
'stop',
|
||||
]);
|
||||
const block = JSON.stringify(legacyTools);
|
||||
// Re-derived for the voice layer manifest (generated layer enums, alias and
|
||||
// field hints), point-and-ask's pointer/referent arguments and the prompt
|
||||
@@ -997,10 +1007,14 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
// list descriptions; the missions still ride existing tools.
|
||||
// Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft.
|
||||
// Air quality (ECCC AQHI) extends the manifest.
|
||||
assert.equal(block.length, 30655, 'serialized tool schema length drifted');
|
||||
// Globe rotation adds one camera motion and its descriptions; Globe View
|
||||
// framing/default guidance updates only move_camera and mode descriptions.
|
||||
// Radio markup and the complete production-request comparison pin locality.
|
||||
// Restore the base camera/Globe View wording with the globe-rotation append.
|
||||
assert.equal(block.length, 30878, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e',
|
||||
'7b95f0f4258896fe5c3316265a19f3a6602809d4e7e681ee7ecd8f2df514ef1f',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
satelliteShortName,
|
||||
fireDetectionKey,
|
||||
accentForSeverity,
|
||||
formatFrpMw,
|
||||
} from '../../data/firmsLabels.js';
|
||||
import {
|
||||
VIEW_PADDING,
|
||||
@@ -480,14 +481,15 @@ export function createModel({
|
||||
}
|
||||
|
||||
function formatFrp(frp) {
|
||||
return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1);
|
||||
return formatFrpMw(frp);
|
||||
}
|
||||
|
||||
/** formatFrp for a card, with the locale's decimal separator ("4,9"). */
|
||||
|
||||
function formatFrpLabel(frp) {
|
||||
const digits = frp >= 10 ? 0 : 1;
|
||||
return formatNumber(frp, {
|
||||
const text = formatFrpMw(frp);
|
||||
const digits = text.includes('.') ? 1 : 0;
|
||||
return formatNumber(Number(text), {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
useGrouping: false,
|
||||
|
||||
@@ -60,6 +60,10 @@ export function createMotion({
|
||||
publishFocusTargetFromCachedPosition,
|
||||
} = services.focus;
|
||||
|
||||
// Fixes leave the cache with their history; corrected positions invalidate
|
||||
// the local frame even when the existing Cartesian is mutated in place.
|
||||
const fixFrames = new WeakMap();
|
||||
|
||||
// Round 5: the old `_warmGroundedAircraftSurfaceCache` (global exact-5-decimal
|
||||
// warm for every grounded contact on Earth) is GONE. Parked-aircraft GPS
|
||||
// jitter minted fresh keys every poll → thousands of upstream points per
|
||||
@@ -124,7 +128,7 @@ export function createMotion({
|
||||
* @returns {Cesium.Cartesian3|null} Estimated ECEF position, or null if no history exists.
|
||||
*/
|
||||
|
||||
function _deadReckon(icao24, result) {
|
||||
function _deadReckon(icao24, result, sampledRenderTime) {
|
||||
const info = flightState.records.data.get(icao24);
|
||||
if (FOCUS_EVIDENCE_DEV && flightState._focusEvidenceIds.has(icao24)) {
|
||||
const position = flightState._billboards.get(icao24)?.position;
|
||||
@@ -149,11 +153,13 @@ export function createMotion({
|
||||
const out = result || new Cesium.Cartesian3();
|
||||
// Render one poll interval behind real time so we interpolate between
|
||||
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(),
|
||||
-RENDER_DELAY_SEC,
|
||||
flightState._scratchRenderTime,
|
||||
);
|
||||
const renderTime =
|
||||
sampledRenderTime ??
|
||||
Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(flightState._scratchRenderTime),
|
||||
-RENDER_DELAY_SEC,
|
||||
flightState._scratchRenderTime,
|
||||
);
|
||||
|
||||
// Bracketing pair: interpolate — no extrapolation error, no snap-back.
|
||||
for (let i = history.length - 1; i >= 1; i--) {
|
||||
@@ -286,12 +292,31 @@ export function createMotion({
|
||||
0,
|
||||
flightState._scratchOffset,
|
||||
);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
|
||||
fix.position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
flightState._scratchEnu,
|
||||
let frame = fixFrames.get(fix);
|
||||
if (!frame) {
|
||||
frame = {
|
||||
position: new Cesium.Cartesian3(),
|
||||
matrix: new Cesium.Matrix4(),
|
||||
};
|
||||
fixFrames.set(fix, frame);
|
||||
}
|
||||
if (
|
||||
!frame.valid ||
|
||||
!Cesium.Cartesian3.equals(frame.position, fix.position)
|
||||
) {
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(
|
||||
fix.position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
frame.matrix,
|
||||
);
|
||||
Cesium.Cartesian3.clone(fix.position, frame.position);
|
||||
frame.valid = true;
|
||||
}
|
||||
return Cesium.Matrix4.multiplyByPoint(
|
||||
frame.matrix,
|
||||
flightState._scratchOffset,
|
||||
out,
|
||||
);
|
||||
return Cesium.Matrix4.multiplyByPoint(enu, flightState._scratchOffset, out);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createMotion } from './motion.js';
|
||||
import { arcOffsetEnu } from '../../data/motionModel.js';
|
||||
|
||||
function fixture() {
|
||||
const flightState = {
|
||||
records: { data: new Map() },
|
||||
_positionHistory: new Map(),
|
||||
_focusEvidenceIds: new Set(),
|
||||
_scratchArc: {},
|
||||
_scratchOffset: new Cesium.Cartesian3(),
|
||||
_scratchRenderTime: new Cesium.JulianDate(),
|
||||
};
|
||||
const motion = createMotion({
|
||||
flightState,
|
||||
services: { groundFloor: {}, camera: {}, focus: {} },
|
||||
parts: {},
|
||||
});
|
||||
return { flightState, motion };
|
||||
}
|
||||
|
||||
function expectedPosition(fix, dt, turnRate, transform) {
|
||||
const arc = {};
|
||||
arcOffsetEnu(fix.velocity, fix.track, turnRate, dt, arc);
|
||||
return Cesium.Matrix4.multiplyByPoint(
|
||||
transform(fix.position, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4()),
|
||||
new Cesium.Cartesian3(arc.east, arc.north, 0),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
}
|
||||
|
||||
function close(actual, expected) {
|
||||
assert.ok(Cesium.Cartesian3.distance(actual, expected) < 1e-8);
|
||||
}
|
||||
|
||||
test('extrapolation reuses fixed frames and invalidates corrected coordinates', (t) => {
|
||||
const { motion } = fixture();
|
||||
const transform = Cesium.Transforms.eastNorthUpToFixedFrame;
|
||||
const spy = t.mock.method(
|
||||
Cesium.Transforms,
|
||||
'eastNorthUpToFixedFrame',
|
||||
transform,
|
||||
);
|
||||
const fix = {
|
||||
position: Cesium.Cartesian3.fromDegrees(-97, 30, 200),
|
||||
velocity: 90,
|
||||
track: 70,
|
||||
};
|
||||
const out = new Cesium.Cartesian3();
|
||||
for (const dt of [-30, 5, 30, 60]) {
|
||||
motion._extrapolateFix(fix, {}, dt, out, 1.5);
|
||||
close(out, expectedPosition(fix, dt, 1.5, transform));
|
||||
}
|
||||
assert.equal(spy.mock.callCount(), 1);
|
||||
|
||||
// Ground reflooring mutates an existing fix's Cartesian in place.
|
||||
Cesium.Cartesian3.fromDegrees(-97, 30, 350, undefined, fix.position);
|
||||
motion._extrapolateFix(fix, {}, 30, out, 1.5);
|
||||
close(out, expectedPosition(fix, 30, 1.5, transform));
|
||||
assert.equal(spy.mock.callCount(), 2);
|
||||
|
||||
fix.position = Cesium.Cartesian3.fromDegrees(140, 80, 1000);
|
||||
motion._extrapolateFix(fix, {}, 30, out, -2);
|
||||
close(out, expectedPosition(fix, 30, -2, transform));
|
||||
assert.equal(spy.mock.callCount(), 3);
|
||||
|
||||
fix.velocity = 210;
|
||||
fix.track = 359;
|
||||
motion._extrapolateFix(fix, {}, 15, out, -1);
|
||||
close(out, expectedPosition(fix, 15, -1, transform));
|
||||
assert.equal(spy.mock.callCount(), 3);
|
||||
|
||||
const replacement = { ...fix };
|
||||
motion._extrapolateFix(replacement, {}, 15, out, -1);
|
||||
close(out, expectedPosition(replacement, 15, -1, transform));
|
||||
assert.equal(spy.mock.callCount(), 4);
|
||||
});
|
||||
|
||||
test('stationary fixes do not build a frame until motion needs it', (t) => {
|
||||
const { motion } = fixture();
|
||||
const spy = t.mock.method(Cesium.Transforms, 'eastNorthUpToFixedFrame');
|
||||
const fix = {
|
||||
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
|
||||
velocity: 0,
|
||||
track: 90,
|
||||
};
|
||||
const out = new Cesium.Cartesian3();
|
||||
close(motion._extrapolateFix(fix, {}, 30, out), fix.position);
|
||||
fix.velocity = 10;
|
||||
close(motion._extrapolateFix(fix, {}, 0, out), fix.position);
|
||||
assert.equal(spy.mock.callCount(), 0);
|
||||
motion._extrapolateFix(fix, {}, 30, out);
|
||||
assert.equal(spy.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
test('fleet render time is shared without resampling or mutating the supplied instant', (t) => {
|
||||
const { flightState, motion } = fixture();
|
||||
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
|
||||
const end = Cesium.JulianDate.addSeconds(start, 60, new Cesium.JulianDate());
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
start,
|
||||
30,
|
||||
new Cesium.JulianDate(),
|
||||
);
|
||||
const savedTime = Cesium.JulianDate.clone(renderTime);
|
||||
const history = [
|
||||
{
|
||||
time: start,
|
||||
position: Cesium.Cartesian3.fromDegrees(-97, 30, 1000),
|
||||
track: 90,
|
||||
},
|
||||
{
|
||||
time: end,
|
||||
position: Cesium.Cartesian3.fromDegrees(-96.99, 30, 1000),
|
||||
track: 90,
|
||||
},
|
||||
];
|
||||
for (const id of ['one', 'two']) {
|
||||
flightState.records.data.set(id, { velocity: 100, true_track: 90 });
|
||||
flightState._positionHistory.set(id, history);
|
||||
}
|
||||
t.mock.method(Cesium.JulianDate, 'now', () => {
|
||||
throw new Error('unexpected per-contact clock read');
|
||||
});
|
||||
const expected = Cesium.Cartesian3.lerp(
|
||||
history[0].position,
|
||||
history[1].position,
|
||||
0.5,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
for (const id of ['one', 'two'])
|
||||
close(
|
||||
motion._deadReckon(id, new Cesium.Cartesian3(), renderTime),
|
||||
expected,
|
||||
);
|
||||
assert.ok(Cesium.JulianDate.equals(renderTime, savedTime));
|
||||
});
|
||||
|
||||
test('tracked callers still sample a fresh delayed time without a fleet epoch', (t) => {
|
||||
const { flightState, motion } = fixture();
|
||||
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
|
||||
const history = [
|
||||
{
|
||||
time: start,
|
||||
position: Cesium.Cartesian3.fromDegrees(-97, 30, 1000),
|
||||
track: 90,
|
||||
},
|
||||
{
|
||||
time: Cesium.JulianDate.addSeconds(start, 60, new Cesium.JulianDate()),
|
||||
position: Cesium.Cartesian3.fromDegrees(-96.99, 30, 1000),
|
||||
track: 90,
|
||||
},
|
||||
];
|
||||
flightState.records.data.set('tracked', { velocity: 100, true_track: 90 });
|
||||
flightState._positionHistory.set('tracked', history);
|
||||
let elapsed = 60;
|
||||
const spy = t.mock.method(Cesium.JulianDate, 'now', (result) => {
|
||||
assert.equal(result, flightState._scratchRenderTime);
|
||||
return Cesium.JulianDate.addSeconds(start, elapsed, result);
|
||||
});
|
||||
const out = new Cesium.Cartesian3();
|
||||
close(
|
||||
motion._deadReckon('tracked', out),
|
||||
Cesium.Cartesian3.lerp(
|
||||
history[0].position,
|
||||
history[1].position,
|
||||
0.5,
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
);
|
||||
elapsed = 75;
|
||||
close(
|
||||
motion._deadReckon('tracked', out),
|
||||
Cesium.Cartesian3.lerp(
|
||||
history[0].position,
|
||||
history[1].position,
|
||||
0.75,
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
);
|
||||
assert.equal(spy.mock.callCount(), 2);
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
TRACKED_MODEL_MIN_PX,
|
||||
TRACKED_MODEL_MAX_PX,
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
RENDER_DELAY_SEC,
|
||||
COURSE_SLEW_DT_MAX_SEC,
|
||||
ROTATION_REFRESH_MS,
|
||||
COURSE_MAX_DPS,
|
||||
@@ -854,6 +855,12 @@ export function createRendering({
|
||||
)
|
||||
: 0.08;
|
||||
flightState._lastFleetTickMs = nowMs;
|
||||
// Every contact in this pass shares the same delayed display instant.
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(flightState._scratchFleetRenderTime),
|
||||
-RENDER_DELAY_SEC,
|
||||
flightState._scratchFleetRenderTime,
|
||||
);
|
||||
|
||||
_drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload
|
||||
if (flightState._cockpitContactMode)
|
||||
@@ -957,7 +964,11 @@ export function createRendering({
|
||||
|
||||
const info = flightState.records.data.get(icao24);
|
||||
|
||||
const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos);
|
||||
const dr = parts.motion._deadReckon(
|
||||
icao24,
|
||||
flightState._scratchFleetPos,
|
||||
renderTime,
|
||||
);
|
||||
// The dead-reckoned point drifts away from the fix whose cell supplied the
|
||||
// height, so a grounded contact's sprite ends up under the mesh it taxied
|
||||
// (or coasted) over. Re-floor at the DISPLAYED coordinate — read-only, and
|
||||
|
||||
@@ -242,12 +242,12 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
flightState._scratchCarto = new Cesium.Cartographic();
|
||||
|
||||
flightState._scratchEnu = new Cesium.Matrix4();
|
||||
|
||||
flightState._scratchArc = { east: 0, north: 0, endCourseDeg: 0 };
|
||||
|
||||
flightState._scratchRenderTime = new Cesium.JulianDate();
|
||||
|
||||
flightState._scratchFleetRenderTime = new Cesium.JulianDate();
|
||||
|
||||
flightState._scratchFleetPos = new Cesium.Cartesian3();
|
||||
|
||||
flightState._scratchSonarScreen = new Cesium.Cartesian2();
|
||||
|
||||
@@ -47,7 +47,7 @@ export function createTracking({
|
||||
const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry;
|
||||
const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout;
|
||||
const { applyTrackedCameraFrame } = services.camera;
|
||||
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
|
||||
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
|
||||
const flightsLayer = layer;
|
||||
|
||||
function _emitAwarenessEvent(type, detail) {
|
||||
@@ -1174,7 +1174,9 @@ export function createTracking({
|
||||
// first-person reference. A globe click must not fall through to the
|
||||
// normal empty-space deselection path; cockpit has explicit exit controls.
|
||||
if (document.body.classList.contains('cockpit-mode')) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const picked = pickContact
|
||||
? pickContact(viewer.scene, click.position)
|
||||
: viewer.scene.pick(click.position);
|
||||
|
||||
if (picked) {
|
||||
// Clicking the tracked entity itself — ignore (don't deselect)
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Event } from 'cesium';
|
||||
import { createLaunchHover } from './lifecycle.js';
|
||||
|
||||
function fakeTimers() {
|
||||
const timers = new Map();
|
||||
let nextId = 1;
|
||||
const api = {
|
||||
setTimer(fn, ms) {
|
||||
const id = nextId++;
|
||||
timers.set(id, { fn, ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
pending: () => [...timers.values()],
|
||||
flush() {
|
||||
const due = [...timers.entries()];
|
||||
timers.clear();
|
||||
for (const [, timer] of due) timer.fn();
|
||||
},
|
||||
};
|
||||
return api;
|
||||
}
|
||||
|
||||
function hoverRig({ overMarker }) {
|
||||
let at = 0,
|
||||
picks = 0,
|
||||
enabled = true,
|
||||
free = true;
|
||||
const timers = fakeTimers();
|
||||
const viewer = {
|
||||
camera: { moveStart: new Event(), moveEnd: new Event() },
|
||||
scene: {
|
||||
canvas: { style: { cursor: '' } },
|
||||
drillPick(position) {
|
||||
picks++;
|
||||
return overMarker(position) ? [{ id: 'launch' }] : [];
|
||||
},
|
||||
},
|
||||
};
|
||||
const hover = createLaunchHover({
|
||||
viewer,
|
||||
enabled: () => enabled,
|
||||
entityLaunchId: (id) => id === 'launch',
|
||||
now: () => at,
|
||||
documentRef: { hidden: false },
|
||||
pointerFree: () => free,
|
||||
setTimer: timers.setTimer,
|
||||
clearTimer: timers.clearTimer,
|
||||
});
|
||||
return {
|
||||
hover,
|
||||
timers,
|
||||
viewer,
|
||||
cursor: () => viewer.scene.canvas.style.cursor,
|
||||
picks: () => picks,
|
||||
setTime: (value) => (at = value),
|
||||
setEnabled: (value) => (enabled = value),
|
||||
setFree: (value) => (free = value),
|
||||
};
|
||||
}
|
||||
|
||||
const onMarker = (position) => position?.x === 1;
|
||||
const marker = () => ({ endPosition: { x: 1, y: 1 } });
|
||||
const empty = () => ({ endPosition: { x: 9, y: 9 } });
|
||||
|
||||
test('mission hover runs one trailing check for a pointer that stops on empty space', () => {
|
||||
const rig = hoverRig({ overMarker: onMarker });
|
||||
rig.hover.move(marker());
|
||||
assert.equal(rig.cursor(), 'pointer');
|
||||
rig.setTime(50);
|
||||
const movement = empty();
|
||||
rig.hover.move(movement);
|
||||
// Cesium reuses its movement object; the trailing check must use a copy.
|
||||
movement.endPosition.x = 1;
|
||||
assert.equal(rig.picks(), 1);
|
||||
assert.deepEqual(
|
||||
rig.timers.pending().map((timer) => timer.ms),
|
||||
[70],
|
||||
);
|
||||
rig.setTime(80);
|
||||
rig.hover.move(empty());
|
||||
assert.equal(rig.timers.pending().length, 1, 'one trailing check at a time');
|
||||
rig.setTime(120);
|
||||
rig.timers.flush();
|
||||
assert.equal(rig.picks(), 2);
|
||||
assert.equal(rig.cursor(), '');
|
||||
assert.equal(rig.timers.pending().length, 0);
|
||||
rig.hover.destroy();
|
||||
});
|
||||
|
||||
test('mission hover trailing check sets the cursor for a pointer that stops on a mission', () => {
|
||||
const rig = hoverRig({ overMarker: onMarker });
|
||||
rig.hover.move(empty());
|
||||
assert.equal(rig.cursor(), '');
|
||||
rig.setTime(50);
|
||||
rig.hover.move(marker());
|
||||
rig.setTime(120);
|
||||
rig.timers.flush();
|
||||
assert.equal(rig.picks(), 2);
|
||||
assert.equal(rig.cursor(), 'pointer');
|
||||
rig.hover.destroy();
|
||||
assert.equal(rig.cursor(), '');
|
||||
});
|
||||
|
||||
test('mission hover trailing check is cancelled by reset, destroy, and camera motion', () => {
|
||||
for (const cancel of [
|
||||
(rig) => rig.hover.reset(),
|
||||
(rig) => rig.hover.destroy(),
|
||||
(rig) => rig.viewer.camera.moveStart.raiseEvent(),
|
||||
]) {
|
||||
const rig = hoverRig({ overMarker: onMarker });
|
||||
rig.hover.move(empty());
|
||||
rig.setTime(50);
|
||||
rig.hover.move(marker());
|
||||
assert.equal(rig.timers.pending().length, 1);
|
||||
cancel(rig);
|
||||
assert.equal(rig.timers.pending().length, 0);
|
||||
assert.equal(rig.picks(), 1);
|
||||
assert.equal(rig.cursor(), '');
|
||||
rig.hover.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('mission hover trailing check rechecks eligibility before picking', () => {
|
||||
for (const block of [
|
||||
(rig) => rig.setEnabled(false),
|
||||
(rig) => rig.setFree(false),
|
||||
]) {
|
||||
const rig = hoverRig({ overMarker: onMarker });
|
||||
rig.hover.move(marker());
|
||||
rig.setTime(50);
|
||||
rig.hover.move(marker());
|
||||
block(rig);
|
||||
rig.setTime(120);
|
||||
rig.timers.flush();
|
||||
assert.equal(rig.picks(), 1);
|
||||
assert.equal(rig.cursor(), '');
|
||||
rig.hover.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('mission hover throttles, clears on motion/hidden, and releases listeners', () => {
|
||||
let at = 0,
|
||||
picks = 0,
|
||||
enabled = true;
|
||||
const listeners = new Set();
|
||||
const doc = {
|
||||
hidden: false,
|
||||
addEventListener: (_n, fn) => listeners.add(fn),
|
||||
removeEventListener: (_n, fn) => listeners.delete(fn),
|
||||
};
|
||||
const viewer = {
|
||||
camera: { moveStart: new Event(), moveEnd: new Event() },
|
||||
scene: {
|
||||
canvas: { style: {} },
|
||||
drillPick() {
|
||||
picks++;
|
||||
return [{ id: 'launch' }];
|
||||
},
|
||||
},
|
||||
};
|
||||
const hover = createLaunchHover({
|
||||
viewer,
|
||||
enabled: () => enabled,
|
||||
entityLaunchId: (id) => id === 'launch',
|
||||
now: () => at,
|
||||
documentRef: doc,
|
||||
pointerFree: () => true,
|
||||
...fakeTimers(),
|
||||
});
|
||||
for (at = 0; at < 120; at++) hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 1);
|
||||
assert.equal(viewer.scene.canvas.style.cursor, 'pointer');
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 2);
|
||||
viewer.camera.moveStart.raiseEvent();
|
||||
at = 500;
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 2);
|
||||
assert.equal(viewer.scene.canvas.style.cursor, '');
|
||||
viewer.camera.moveEnd.raiseEvent();
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 3);
|
||||
doc.hidden = true;
|
||||
for (const fn of listeners) fn();
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 3);
|
||||
assert.equal(viewer.scene.canvas.style.cursor, '');
|
||||
doc.hidden = false;
|
||||
enabled = false;
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 3);
|
||||
hover.destroy();
|
||||
assert.equal(listeners.size, 0);
|
||||
assert.equal(viewer.camera.moveStart.numberOfListeners, 0);
|
||||
assert.equal(viewer.camera.moveEnd.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('mission hover preserves the cursor of a pointer-owning tool', () => {
|
||||
let free = true,
|
||||
picks = 0;
|
||||
const viewer = {
|
||||
camera: { moveStart: new Event(), moveEnd: new Event() },
|
||||
scene: {
|
||||
canvas: { style: { cursor: '' } },
|
||||
drillPick() {
|
||||
picks++;
|
||||
return [{ id: 'launch' }];
|
||||
},
|
||||
},
|
||||
};
|
||||
const hover = createLaunchHover({
|
||||
viewer,
|
||||
enabled: () => true,
|
||||
entityLaunchId: () => true,
|
||||
documentRef: null,
|
||||
pointerFree: () => free,
|
||||
});
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(viewer.scene.canvas.style.cursor, 'pointer');
|
||||
free = false;
|
||||
viewer.scene.canvas.style.cursor = 'crosshair';
|
||||
hover.move({ endPosition: {} });
|
||||
viewer.camera.moveStart.raiseEvent();
|
||||
hover.destroy();
|
||||
assert.equal(picks, 1);
|
||||
assert.equal(viewer.scene.canvas.style.cursor, 'crosshair');
|
||||
});
|
||||
@@ -1,6 +1,116 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
const HOVER_PICK_INTERVAL_MS = 120;
|
||||
|
||||
/**
|
||||
* Bound hover picking independently of precise click selection. A move inside
|
||||
* the throttle window is not dropped: its position is kept and one trailing
|
||||
* check runs when the window ends, so a pointer that stops is still resolved.
|
||||
*/
|
||||
export function createLaunchHover({
|
||||
viewer,
|
||||
enabled,
|
||||
entityLaunchId,
|
||||
now = () => performance.now(),
|
||||
documentRef = globalThis.document,
|
||||
pointerFree = isPointerFree,
|
||||
setTimer = globalThis.setTimeout,
|
||||
clearTimer = globalThis.clearTimeout,
|
||||
}) {
|
||||
let moving = false;
|
||||
let lastPick = -Infinity;
|
||||
let ownsCursor = false;
|
||||
let pendingPosition = null;
|
||||
let trailingTimer = null;
|
||||
const clearCursor = () => {
|
||||
if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer')
|
||||
viewer.scene.canvas.style.cursor = '';
|
||||
ownsCursor = false;
|
||||
};
|
||||
const cancelTrailing = () => {
|
||||
if (trailingTimer !== null) clearTimer(trailingTimer);
|
||||
trailingTimer = null;
|
||||
pendingPosition = null;
|
||||
};
|
||||
const reset = () => {
|
||||
cancelTrailing();
|
||||
clearCursor();
|
||||
lastPick = -Infinity;
|
||||
};
|
||||
const start = () => {
|
||||
moving = true;
|
||||
reset();
|
||||
};
|
||||
const end = () => {
|
||||
moving = false;
|
||||
reset();
|
||||
};
|
||||
const visibility = () => {
|
||||
if (documentRef?.hidden || documentRef?.visibilityState === 'hidden')
|
||||
reset();
|
||||
};
|
||||
const eligible = () =>
|
||||
enabled() &&
|
||||
!moving &&
|
||||
!documentRef?.hidden &&
|
||||
documentRef?.visibilityState !== 'hidden' &&
|
||||
pointerFree();
|
||||
const pick = (position, at) => {
|
||||
lastPick = at;
|
||||
const found = viewer.scene
|
||||
.drillPick(position, 12)
|
||||
.some((picked) => entityLaunchId(picked?.id));
|
||||
if (found) {
|
||||
viewer.scene.canvas.style.cursor = 'pointer';
|
||||
ownsCursor = true;
|
||||
} else clearCursor();
|
||||
};
|
||||
const trailing = () => {
|
||||
trailingTimer = null;
|
||||
const position = pendingPosition;
|
||||
pendingPosition = null;
|
||||
if (!position) return;
|
||||
// Eligibility may have changed while the check waited.
|
||||
if (!eligible()) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
pick(position, now());
|
||||
};
|
||||
const removeStart = viewer.camera?.moveStart?.addEventListener(start);
|
||||
const removeEnd = viewer.camera?.moveEnd?.addEventListener(end);
|
||||
documentRef?.addEventListener?.('visibilitychange', visibility);
|
||||
return {
|
||||
move(movement) {
|
||||
if (!eligible()) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const at = now();
|
||||
const wait = HOVER_PICK_INTERVAL_MS - (at - lastPick);
|
||||
if (wait > 0) {
|
||||
// Cesium reuses the movement object, so keep a copy of the position.
|
||||
pendingPosition = Cesium.Cartesian2.clone(
|
||||
movement.endPosition,
|
||||
pendingPosition ?? undefined,
|
||||
);
|
||||
if (trailingTimer === null) trailingTimer = setTimer(trailing, wait);
|
||||
return;
|
||||
}
|
||||
cancelTrailing();
|
||||
pick(movement.endPosition, at);
|
||||
},
|
||||
reset,
|
||||
destroy() {
|
||||
reset();
|
||||
removeStart?.();
|
||||
removeEnd?.();
|
||||
documentRef?.removeEventListener?.('visibilitychange', visibility);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -8,6 +118,7 @@ export function createLifecycle({
|
||||
source,
|
||||
}) {
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
let hover = null;
|
||||
|
||||
const methods = {
|
||||
init(viewer) {
|
||||
@@ -45,14 +156,15 @@ export function createLifecycle({
|
||||
layerState._moveHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
viewer.scene.canvas,
|
||||
);
|
||||
layerState._moveHandler.setInputAction((movement) => {
|
||||
if (!layerState._enabled || !layerState._dataSource?.show) return;
|
||||
const missionEntity = viewer.scene
|
||||
.drillPick(movement.endPosition, 12)
|
||||
.map((picked) => picked?.id)
|
||||
.find((candidate) => parts.selection.entityLaunchId(candidate));
|
||||
viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : '';
|
||||
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
||||
hover = createLaunchHover({
|
||||
viewer,
|
||||
enabled: () => layerState._enabled && layerState._dataSource?.show,
|
||||
entityLaunchId: parts.selection.entityLaunchId,
|
||||
});
|
||||
layerState._moveHandler.setInputAction(
|
||||
hover.move,
|
||||
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
|
||||
);
|
||||
// Apply horizon visibility before Cesium traverses and picks the scene.
|
||||
// A postRender write is one frame late and can remain visibly stale when
|
||||
// request-on-demand rendering stops after a globe camera move.
|
||||
@@ -100,6 +212,7 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
async disable() {
|
||||
hover?.reset();
|
||||
layerState._sourceController.abort();
|
||||
layerState._enabled = false;
|
||||
releaseContinuousRender('rocket-launches');
|
||||
@@ -122,6 +235,8 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
async destroy(viewer) {
|
||||
hover?.destroy();
|
||||
hover = null;
|
||||
layerState._sourceController.abort();
|
||||
releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix)
|
||||
layerState._enabled = false;
|
||||
|
||||
@@ -47,7 +47,7 @@ export function createTracking({
|
||||
const { ensureGeoidReady, geoidHeight } = services.geoid;
|
||||
const { clearFocusTarget } = services.focus;
|
||||
const { applyTrackedCameraFrame } = services.camera;
|
||||
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
|
||||
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
|
||||
|
||||
function _emitAwarenessEvent(type, detail) {
|
||||
if (
|
||||
@@ -593,7 +593,7 @@ export function createTracking({
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget adsb.lol readsb trace backfill (PRD F2). Trace points are
|
||||
* Fire-and-forget optional source history backfill (PRD F2). Trace points are
|
||||
* stride-thinned to the remaining vertex budget, then spliced strictly older
|
||||
* than the oldest seeded fix AHEAD of the locally accumulated fine segment,
|
||||
* capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/
|
||||
@@ -1079,7 +1079,9 @@ export function createTracking({
|
||||
// first-person reference. Empty globe clicks are inert until the user
|
||||
// exits with C, Escape, or the dedicated button.
|
||||
if (document.body.classList.contains('cockpit-mode')) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const picked = pickContact
|
||||
? pickContact(viewer.scene, click.position)
|
||||
: viewer.scene.pick(click.position);
|
||||
|
||||
if (picked) {
|
||||
// Clicking the tracked entity itself -- ignore (don't deselect)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { msg } from '../../i18n/index.js';
|
||||
import {
|
||||
RADIO_DIRECTORY_STALE_MS,
|
||||
RADIO_DIRECTORY_FUTURE_SKEW_MS,
|
||||
RADIO_REJECTED_STATION_COUNT_MAX,
|
||||
} from './policy.js';
|
||||
|
||||
// Directory errors stay English in layer state and stats; the layer row and
|
||||
@@ -64,17 +65,24 @@ export function createIngestion({
|
||||
typeof body.degraded !== 'boolean'
|
||||
)
|
||||
throw new Error('Radio directory freshness metadata was malformed');
|
||||
// Rows the server skipped as structurally malformed. Older envelopes
|
||||
// omit the field; a present value must be a bounded count.
|
||||
const serverRejectedStationCount =
|
||||
body.rejectedStationCount === undefined
|
||||
? 0
|
||||
: body.rejectedStationCount;
|
||||
if (
|
||||
!Number.isSafeInteger(serverRejectedStationCount) ||
|
||||
serverRejectedStationCount < 0 ||
|
||||
serverRejectedStationCount > RADIO_REJECTED_STATION_COUNT_MAX
|
||||
)
|
||||
throw new Error('Radio directory diagnostic metadata was malformed');
|
||||
const rows = body.stations;
|
||||
const acceptedRows = rows.filter(
|
||||
parts.catalogModel.isValidRadioDirectoryStation,
|
||||
);
|
||||
if (!acceptedRows.length)
|
||||
throw new Error('Radio directory returned no usable stations');
|
||||
if (acceptedRows.length !== rows.length) {
|
||||
throw new Error(
|
||||
'Radio directory response contained malformed stations',
|
||||
);
|
||||
}
|
||||
const acceptedGeneration = body.acceptedGeneration;
|
||||
if (
|
||||
acceptedGeneration !== null &&
|
||||
@@ -147,7 +155,15 @@ export function createIngestion({
|
||||
}
|
||||
if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt;
|
||||
}
|
||||
layerState._degraded = body.degraded;
|
||||
// Combine server-side structural rejections with rows this client
|
||||
// refused. The count is diagnostic: it marks coverage degraded but does
|
||||
// not undo the generation admitted above.
|
||||
layerState._rejectedStationCount = Math.min(
|
||||
RADIO_REJECTED_STATION_COUNT_MAX,
|
||||
serverRejectedStationCount + rows.length - acceptedRows.length,
|
||||
);
|
||||
layerState._degraded =
|
||||
body.degraded || layerState._rejectedStationCount > 0;
|
||||
layerState._stale = body.stale;
|
||||
layerState._error = preservingWarmCatalog
|
||||
? ERROR_REFRESH_DEGRADED
|
||||
|
||||
@@ -108,6 +108,7 @@ export function createLifecycle({
|
||||
layerState._loading = false;
|
||||
layerState._stale = false;
|
||||
layerState._degraded = false;
|
||||
layerState._rejectedStationCount = 0;
|
||||
layerState._error = null;
|
||||
layerState._updatedAt = null;
|
||||
layerState._managerLifecyclePresentation = null;
|
||||
|
||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user