fix: resolve review findings and refresh PR #1518 against main

This commit is contained in:
Said Turksever committed 2026-11-03 16:17:30 +00:00
commit a0924c00d7
89 files changed
+3628 -602

No files matched your search

+3
View File
@@ -98,6 +98,8 @@ How to read this:
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
| **OpenSnowMap** (`www.opensnowmap.org`) | Ski Pistes layer: raster overlay of ski runs and lifts (#10) | Tiles CC BY-SA; data ODbL 1.0 via OpenStreetMap; bulk tile download forbidden without prior agreement | "Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)" |
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover |
### Local receiver input (not fetched from a service)
@@ -118,6 +120,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
- **Sea Marks and Ski Pistes (#10).** Two keyless raster overlay layers under Infrastructure, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline.
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
<a id="gnss-integrity"></a>
+11 -8
View File
@@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat
## ⚡ Quick Start
**Start without an account or API keys.** Every path opens the same app with
**Local setup needs no account or API keys.** The app opens with
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
unreachable. Flights, military traffic, satellites, earthquakes, public
cameras, radio, and launches are available without keys.
@@ -212,7 +212,7 @@ Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front o
On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`.
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)).
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). The map routes compare the browser's `Origin` with the `Host` the server sees, so the proxy must pass the original `Host` and, when it terminates TLS, `X-Forwarded-Proto`. Caddy does both by default; for nginx add `proxy_set_header Host $http_host;`, `proxy_set_header X-Forwarded-Proto $scheme;` and `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`. On an install outside Docker, that forwarding header also rate-limits the proxy's users apart from your own browser. Under the default Docker bridge network every client on the host, your browser included, reaches the container from its gateway address and shares one Overpass bucket.
</details>
@@ -236,7 +236,11 @@ the container.
### Path 4 — GitHub Codespaces (cloud, no local install)
Runs the same dev server in a cloud workspace, so Node and the dependencies
Codespaces requires a GitHub account. Overpass queries and OpenAI voice token
requests return HTTP 403 through its forwarded HTTPS URL, even with API keys
in `.env` and `GEV_ALLOWED_HOSTS` configured. Use a local setup for these features.
The codespace runs the dev server in a cloud workspace, so Node and the dependencies
never touch your machine. The globe still renders in your own browser, so it
needs the same graphics there as any other path. GitHub Free includes 120
core-hours a month with no card on file — about 60 hours on the default 2-core
@@ -265,14 +269,13 @@ ignored on purpose, so pass that exact name.
⚠️ **A codespace is reachable from the internet.** Treat it as the
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
Settings does not open on the forwarded URL, so put keys in the workspace
`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the
threat model.
Settings is unavailable on the forwarded URL. Never commit your workspace
`.env`. [SECURITY.md](SECURITY.md) has the threat model.
### Then power it up — in the app, not in a file
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
chip in the bottom-right corner: Provider Settings lists every supported key,
For a local setup, keys are upgrades, not prerequisites. When you want one,
click the **POWER UP** chip in the bottom-right corner: Provider Settings lists every supported key,
what it switches on, and where to get it. Paste, hit **SAVE KEYS**, and the app
restarts itself with the new capability on. Once everything is configured the
chip reads **POWERED UP** — and if a compact layout hides it, `?setup=1`
+1 -1
View File
@@ -80,7 +80,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights` and `/api/overpass`) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map needs both to render behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
+3
View File
@@ -0,0 +1,3 @@
- Add Sea Marks (OpenSeaMap) and Ski Pistes (OpenSnowMap) as two raster
overlay layers under Infrastructure. They drape on the globe stacks and on
Google 3D, and share links keep each one (Tom-Neverwinter, #114).
@@ -0,0 +1,13 @@
- Keep Overpass-backed layers working behind a reverse proxy: `/api/overpass`
now uses the keyless same-site gate, admits forwarding headers and still
refuses cross-site requests. Direct loopback requests keep their own
admission window; proxied requests, including those a local reverse proxy
relays over loopback, are admitted as remote traffic keyed by their socket,
and IPv6 peers are keyed by /64, so neither LAN peers nor proxied users can
exhaust the owner's admission. The upstream budget stays shared, so an owner
cache miss can still be rate-limited (asturksever, #1308).
- Give the tracking arrival check a wall-clock budget per observed frame and
retry only an inconclusive timeout: a timeout keeps every angle it
measured, including a late frame's, and a wrong one fails without retry, so slow software-rendered
frames no longer fail the check and a real violation is never retried into a
pass (asturksever, #1308).
@@ -0,0 +1,5 @@
- Military aircraft that leave the adsb.lol feed fade and are removed again.
The feed's routine position-less rows no longer mark every snapshot partial,
which kept vanished aircraft at full opacity for up to five minutes; those
rows keep only their own aircraft, and a likely-landed one still leaves on
the landed limit (Aditya, @Rohilalala, #1435).
@@ -0,0 +1,3 @@
- A terrain request the proxy can only partly answer (a 429 pause, or a point
the upstream returns without a height) now keeps the real heights it has
instead of putting the whole chunk on the geoid (quanticsoul4772, #1456).
@@ -0,0 +1,5 @@
- Radio Next, Previous and the tuning dial no longer start a broadcaster stream
before Play is pressed; while a stream plays, it follows the new station.
Play reads Pause while a stream connects, and a tuner release onto a station
the directory removed or changed no longer leaves a Resume for another
station (gekh, #1474).
+3
View File
@@ -0,0 +1,3 @@
- Write the AI HUD summary in the interface language, led by the HUD's own
translated feed-state warning. A partial layer no longer reads as off or
nominal in the HUD, voice and analyst answers (German, @gekh, #1488).
+65 -46
View File
@@ -1197,12 +1197,12 @@ addition to the locally accumulated trail, never a promise of complete coverage.
Snapshot coverage, completeness and freshness are separate fields. A partially
admitted aircraft snapshot retains absent contacts for up to five minutes before
the usual missed-poll eviction. An OpenSky row with an ICAO24 address but null
longitude and latitude is a contact without a position, not a malformed row: it
leaves the snapshot complete and is listed in `retainIds`, so civil flights
retain that aircraft under the same five-minute bound, unless it is likely
landed, while other absent aircraft fade and leave on the missed-poll limits. A
nonempty snapshot with no positioned rows is still malformed. Military readsb
admission is unchanged. Unknown snapshot times remain unknown in stats. An invalid nonempty
longitude and latitude, or a readsb row with a hex but no `lat`/`lon`, is a
contact without a position, not a malformed row: it leaves the snapshot complete
and is listed in `retainIds`, so civil and military flights retain that aircraft
under the same five-minute bound, unless it is likely landed, while other absent
aircraft fade and leave on the missed-poll limits. A nonempty snapshot with no
positioned rows is still malformed. Unknown snapshot times remain unknown in stats. An invalid nonempty
snapshot retains the previous display. Empty vessel refreshes retain the existing
first-connect grace and warm-data behavior. Known source failures have bounded
messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
@@ -3092,9 +3092,10 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> take over under the Cockpit cap of 60. Loading/capped contacts remain 2D,
> out-of-range contacts use rotation-free dots, and exit restores map treatment.
> Its Radio popover exposes compact power,
> transport, station, and volume controls. Cockpit Previous/Next preserves
> selection, autoplay, and broadcaster fallback audio without starting the
> map-view station flights that compete with the first-person camera. These
> transport, station, and volume controls. Cockpit Previous/Next changes the
> selection without starting the map-view station flights that compete with
> the first-person camera; like the Context Previous/Next buttons, it plays
> (with broadcaster fallback) only while a stream is already active. These
> are a Cockpit-only accordion:
> each static header uses the left accordion's label and divider with a dedicated
> directional-chevron disclosure button and no full-row hover treatment. Only one
@@ -3257,16 +3258,25 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> outlasts its retries, the proxy pauses upstream calls for the `Retry-After`
> (60 seconds when none, at most 10 minutes). A request whose points are all
> cached, fresh or stale, is still served; a request with any uncached point
> gets 502 and the page uses its geoid fallback for that chunk. Each request
> gets 502, whose `partial` lists the real heights it has (null for the rest)
> when it has any, and the page keeps those and uses its geoid fallback only
> for the points without one. The same holds when the upstream answers a
> point with a null height. Each request
> that was waiting on the upstream when the 429 came back logs one warning;
> requests refused during the pause do not log. Client geoid
> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the
> first later successful fetch. Smaller chunks reduce timeout risk; complete
> camera batches still wait for their sequential requests, and unresolved
> placement continues to use the existing prior until real heights arrive.
> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded
> POSTs through the shared local-request gate, then limits requests to 300/min
> per socket peer and 900/min globally before reading or validating bodies.
> - **Overpass cache admission:** `/api/overpass` refuses cross-site POSTs
> through the keyless same-site gate (reverse-proxy forwarding headers are
> admitted, as for `/api/terrain/heights`), then limits requests to 300/min
> per peer — IPv6 peers keyed by /64 — and 900/min across all remote peers
> before reading or validating bodies. Direct loopback (no forwarding
> headers) has its own 300/min window outside that global; a local reverse
> proxy's requests carry forwarding headers and are counted as remote, keyed
> `proxied:<socket peer>`, so they cannot spend the owner's window. Upstream
> quota stays shared, so owner cache misses can still hit 429.
> Tag-filter brackets are scanned once; nested or unmatched brackets return 400.
> It then checks fresh memory, identical in-flight work, and fresh disk entries
> before invoking its separate local 90/min upstream limiter. Cache and
@@ -3990,31 +4000,33 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
requests use Play; a qualified Play-shaped tool call is normalized to Select so
its criteria cannot be silently ignored.
| Layer | Source | File | Proxy | Update Interval |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
| Layer | Source | File | Proxy | Update Interval |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
@@ -4736,10 +4748,12 @@ accepted snapshot, and feed telemetry before re-initialization; monotonic
ownership tokens plus the session boundary keep late callbacks from a retired
session inert.
A tuner drag resolves previews against the single accepted snapshot captured at
pointer-down, even while a newer catalog is admitted. Release starts playback
only when the current same-ID record still exactly matches the frozen
presentation and stream metadata; removal or replacement reports the channel as
unavailable and never silently retargets the drag. A cold degraded fallback may
pointer-down, even while a newer catalog is admitted. Like Previous/Next,
release starts playback only if a stream was active when the drag began;
otherwise it just selects the station. It commits only when the current same-ID
record still exactly matches the frozen presentation and stream metadata.
Removal or replacement reports the channel as unavailable and never silently
retargets the drag. A cold degraded fallback may
still populate the directory and globe, but its null accepted generation cannot
populate or begin the tuner.
@@ -4916,7 +4930,9 @@ before the later control succeeds, only its playback handoff is suppressed; its
result reads enabled state from the manager and audio state from the Radio
module. Voice Pause is a playback-only no-op while the layer is disabled; it
never enables Radio and reports a fulfilled-false pause as failure. A
newer-response or user-origin Stop still cancels stale handoff work.
newer-response or user-origin Stop still cancels stale handoff work, as do a
user Pause, a user Previous/Next that only selects another station, and a tuner
release onto a station the directory removed or changed mid-drag.
Only a newer user turn or session teardown aborts the complete active-tool set.
A cancelled turn cannot publish a late Radio playback request. Dedicated Radio
controls and generic layer-visibility commands both forward cancellation.
@@ -5406,8 +5422,11 @@ and unreachable upstream (502/504) separately from road geometry.
### AI HUD Summary (June 2026)
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
- Input: place/street/nearby-place labels, enabled layers and `feedProvenance`, with layer names as the user sees them; outside English the narration note and per-layer state ids are left out (`overall` carries the state). No model call without a place, street or nearby-place label: the deterministic line is shown.
- Language: the HUD sends `locale` (part of the dedupe signature); the proxy keeps a shipped locale, English otherwise. Switching language in place drops the reply and aborts the request in flight, so the next tick asks again.
- Reply: `{summary, feedState}` (`text.format` JSON schema, `feedState` a snapshot state or null). A view that is not nominal needs the matching `feedState`. The HUD adds its own translated tag, state first and layer names last (`STALE | … | LIVE FLIGHTS`), so the warning is never clipped. Outside English a reply falls back when it carries a feed-state id, a translated layer's English name or id, only Latin letters in a non-Latin language, or, with every feed healthy, the UNAVAILABLE word. `get_hud_caption` uses the same check and tag order, in English, and returns `feedState`.
- The model is asked for at most five words; the proxy keeps up to 80 characters, or 20 for Japanese and Chinese, cut at a word boundary unless the first word is longer. It does not count words, since the model reads a name like Rue de Rivoli as one. Errors and the 5 s timeout fall back to the deterministic line; a camera move during typing shows that line instead of the fragment.
- `partial` is a feed state of its own, ranked between `fallback` and `nominal`, so one partial layer, or partial plus nominal, reads partial, not off or nominal (HUD, voice view state, `analyst_query`, `brief`, the agent world packet). Its narration note says coverage is incomplete rather than not live.
- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable.
### Place-search providers
@@ -5692,7 +5711,7 @@ pins and share links already supply complete boxes.
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the first reveal within a bounded wall-clock window before the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the settle frame and then the first reveal frame, each within a 500 ms wall-clock budget that sits inside the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. Only an inconclusive timeout is retried (once); a timeout keeps every angle it measured (the settle frame before a reveal timeout, and the late frame itself, which is read before the deadline check), so a measured wrong angle fails without retry. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
### TR-3B conversion Easter egg (August 2026)
+3 -95
View File
@@ -1102,6 +1102,7 @@
"scripts/qa-anime-sky.mjs",
"scripts/qa-sky-model-lighting.mjs",
"server/providers/overpass/requestAdmission.test.mjs",
"src/tooling/trackArrivalObservation.mjs",
"src/tooling/trackArrivalObservation.test.mjs",
"src/ui/styles/status-badge.css",
"src/ui/cctvBadges.test.mjs",
@@ -1110,99 +1111,6 @@
"src/cameraGlobeRotation.test.mjs",
"src/voice/globeResetConfirmation.test.mjs",
"src/hudDms.test.mjs",
"src/hudSummaryGate.test.mjs",
"src/voice/hudPlaceLabels.test.mjs",
"src/voice/panel/preferences.test.mjs",
"src/voice/panel/preferencesView.test.mjs",
"src/voice/voiceControlView.test.mjs",
"src/voice/panel/host.test.mjs",
"src/voice/panel/manualPanel.test.mjs",
"src/voice/panel/nativeActivity.test.mjs",
"src/voice/panel/inactivity.test.mjs",
"src/voice/panel/actionObserver.test.mjs",
"src/voice/realtimeCostPanel.test.mjs",
"docs/SHARE-LINK-FORMAT.md",
"src/share/wire.test.mjs",
"src/share/viewCodec.test.mjs",
"src/share/legacyShareState.test.mjs",
"src/testSupport/shareViewFixtures.mjs",
"src/ui/shareLinks.test.mjs",
"src/ui/shareButton.test.mjs",
"src/share/shareLinkPath.test.mjs",
"src/ui/kit/UiRail.test.mjs",
"src/ui/kit/UiSegmented.vue",
"src/ui/kit/UiSegmented.test.mjs",
"src/ui/kit/UiSlider.js",
"src/ui/kit/UiSlider.test.mjs",
"src/ui/kit/UiButton.vue",
"src/ui/kit/UiButton.test.mjs",
"src/ui/kit/UiChip.vue",
"src/ui/kit/UiChip.test.mjs",
"src/ui/globeActions.test.mjs",
"src/ui/safeFrameView.test.mjs",
"src/ui/kit/UiSelect.js",
"src/ui/kit/UiSelect.test.mjs",
"src/ui/kit/UiBadge.vue",
"src/ui/kit/UiBadge.test.mjs",
"src/ui/kit/UiEmpty.vue",
"src/ui/rail/RailChips.vue",
"src/ui/displayPanelActions.test.mjs",
"src/testSupport/vueDom.mjs",
"build/vueLoader.js",
"build/registerVue.js",
"src/ui/panelNavigation.test.mjs",
"src/ui/stores/owners.js",
"src/ui/stores/owners.test.mjs",
"src/ui/stores/panels.test.mjs",
"src/ui/stores/status.js",
"src/ui/statusChipsView.test.mjs",
"src/ui/statusChipOwners.test.mjs",
"src/ui/toast.js",
"src/ui/toastView.test.mjs",
"src/ui/stores/sceneInteractions.js",
"src/scenes/dataPacks/presentationView.test.mjs",
"src/ui/stores/dataPacks.js",
"src/scenes/interactionsView.test.mjs",
"src/layers/wind/inspectionMarker.js",
"src/layers/wind/inspectionMarker.test.mjs",
"src/ui/stores/windInspection.js",
"src/ui/stores/missionReplay.js",
"src/layers/launches/replayOverlay.test.mjs",
"src/ui/stores/bhoteKoshiPanel.js",
"src/data/bhoteKoshiPanelView.test.mjs",
"src/ui/stores/celestialRing.js",
"src/celestialRingView.test.mjs",
"src/ui/stores/screenAnnotations.js",
"src/ui/stores/worldOverlayActions.js",
"src/overlays/worldOverlayActionsView.test.mjs",
"src/ui/stores/awarenessDirection.js",
"src/layers/awareness/directionOverlay.test.mjs",
"src/layers/awareness/rangeRing.test.mjs",
"src/hudReadouts.js",
"src/ui/stores/hud.js",
"src/ui/views/intelHudView.test.mjs",
"src/testSupport/hudReadouts.mjs",
"src/ui/stores/firstRun.js",
"src/firstRunLauncherView.test.mjs",
"src/firstRunCopy.js",
"src/data/headingDegrees.test.mjs",
"src/data/distanceFormat.test.mjs",
"src/sources/adsbFields.test.mjs",
"src/sources/phaseTiming.test.mjs",
"scripts/build-census-places.mjs",
"src/data/placeBoundaries.test.mjs",
"src/data/boundaryLabels.test.mjs",
"src/data/packGeometry.test.mjs",
"src/annotations/placeOutline.test.mjs",
"src/annotations/outlineIdentity.test.mjs",
"src/annotations/outlineRungs.test.mjs",
"src/annotations/outlineRungsFallback.test.mjs",
"src/search/photonSetting.test.mjs",
"src/sources/nominatimOutlines.test.mjs",
"src/sources/openFreeMapOutlines.test.mjs",
"src/sources/openFreeMapOutlinesEdges.test.mjs",
"src/standalone/layerSources.test.mjs",
"src/tooling/nominatimGuardLifecycle.test.mjs",
"src/tooling/nominatimLockOwnership.test.mjs",
"src/tooling/nominatimOutlineRoute.test.mjs"
"server/providers/common/rateLimitKeys.test.mjs",
"src/layers/awareness/rangeRing.test.mjs"
]
+3
View File
@@ -485,6 +485,7 @@
"exports": ["./server/providers/openai"],
"modules": [
"src/i18n/marker.js",
"src/i18n/locale.js",
"src/hudLayoutPolicy.js",
"server/providers/common/rate-limit.js",
"server/providers/common/request.js",
@@ -2352,6 +2353,7 @@
"src/data/groundSnap.js",
"src/data/iconOrientation.js",
"src/data/infrastructure.js",
"src/data/osmOverlays.js",
"src/data/installationFeedback.js",
"src/data/labelArbiter.js",
"src/data/idSwitches.js",
@@ -3282,6 +3284,7 @@
"src/data/geoid.js",
"src/data/iconOrientation.js",
"src/data/infrastructure.js",
"src/data/osmOverlays.js",
"src/data/installationFeedback.js",
"src/data/satellitePass.js",
"src/data/labelArbiter.js",
+20 -3
View File
@@ -3073,20 +3073,27 @@ async function main() {
expectedSlot: targetIndex,
previewPlayDelta: previewPlays - playsBefore,
commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore,
audioState: state.audioState,
tuningStatic: state.tuningStatic,
samples,
spread: Math.max(...samples) - Math.min(...samples),
};
});
check(
'pointer preview never plays and release commits the nearest station once without a needle sweep',
'from a stopped Radio, pointer release only selects the nearest station, silently and without a needle sweep',
tunerDirectRelease.targetId
&& tunerDirectRelease.selectedId === tunerDirectRelease.targetId
&& tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot
&& tunerDirectRelease.previewPlayDelta === 0
&& tunerDirectRelease.commitPlayDelta === 1
&& tunerDirectRelease.commitPlayDelta === 0
&& tunerDirectRelease.audioState === 'stopped'
&& !tunerDirectRelease.tuningStatic
&& tunerDirectRelease.spread < 0.5,
JSON.stringify(tunerDirectRelease),
);
// The release plays only if a stream was active when the drag began.
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
// Earlier cases scroll and resize the directory. Bring the physical drag
// target back into its scroller before deriving viewport mouse coordinates.
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
@@ -3494,6 +3501,9 @@ async function main() {
});
});
await sleep(500);
// Next left the Radio stopped; a release plays only from an active stream.
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
const failedTuner = await page.evaluate(async () => {
const gev = window.__godsEyeView;
const slider = document.getElementById('radio-tuner-slider');
@@ -3692,7 +3702,14 @@ async function main() {
radio.stopPlayback();
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
// Next plays only while a stream is active, so start from a playing station.
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: true, focus: false });
});
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
await page.evaluate(() => {
const gev = window.__godsEyeView;
const camera = gev.viewer.camera;
const radio = gev.dataManager.layers.get('radio').module;
const pool = gev.styleManager._radioControls._radioTunerPool;
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
+26 -9
View File
@@ -94,7 +94,7 @@
import fs from 'node:fs';
import puppeteer from 'puppeteer';
import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
@@ -2698,16 +2698,18 @@ async function main() {
// Staged deterministically: park the camera nadir over the synthetic
// cluster, let rotations settle, TAMPER a billboard's rotation, then
// trigger each path with the camera idle. The fleet runs every 80 ms,
// independently of rendered FPS. Observe the first completed settle and
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
// rotation refresh can repair an incorrect first reveal.
// independently of rendered FPS. Observe the first completed settle frame
// within 500 ms of forcing moveEnd and the first reveal frame within
// 500 ms of that settle frame: each budget sits inside the 1 s idle
// rotation refresh that could otherwise repair an incorrect first reveal,
// and a ~300 ms headless SwiftShader frame fits inside each of them.
// ============================================================
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
const arrival = await evalPage(async () => {
const v = window.__godsEyeView.viewer;
const dm = window.__godsEyeView.dataManager;
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
const { observeArrivalRotationWithRetry } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
// 3D models OFF for this phase: a model-handed-off billboard is hidden
// and skips rotation updates entirely — the probes need live billboards
// (this is also the app's default state the field report came from).
@@ -2769,7 +2771,23 @@ async function main() {
const bb = findBB(id);
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
const observed = await observeArrivalRotation({
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
// A wall-clock timeout is inconclusive, not a wrong angle: the frame
// landed where the 1 s idle refresh could already have repaired it
// (seen once on headless SwiftShader: a 926 ms first frame). Such an
// observation gets one more attempt; one that measured a wrong angle —
// a wrong first reveal, or a wrong settle frame before a reveal
// timeout — never does.
const observed = await observeArrivalRotationWithRetry({
async prepare() {
// Freshen both layers' rotation passes right before the probe, so
// the settle frame is measured against a pass that just ran rather
// than one left over from the previous probe — with the camera
// idle, a layer otherwise runs a pass only every
// ROTATION_REFRESH_MS.
v.camera.moveEnd.raiseEvent();
await nextFrames(1);
},
scene: v.scene,
startMoveEnd() {
// Only moveEnd can repair the tampered angle at the idle pose.
@@ -2790,7 +2808,6 @@ async function main() {
};
},
});
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
return { r0, ...observed, hasModel };
};
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
@@ -2800,8 +2817,8 @@ async function main() {
military: await probe('bbb101', militaryCourse),
};
});
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`);
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; ${arrivalAngleViolated(a) ? 'angle violation (not retried); ' : ''}${Number.isFinite(a.dMoveEnd) ? `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° ` : ''}${Number.isFinite(a.dReveal) ? `reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° ` : ''}elapsed=${Number(a.elapsedMs).toFixed(1)} ms attempts=${a.attempts}`
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms attempts=${a.attempts} hasModel=${a.hasModel}`);
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
+44
View File
@@ -1,3 +1,4 @@
import { isIPv6 } from 'node:net';
import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
@@ -60,3 +61,46 @@ export function makeCostRateLimiter(envValue, defaultPerMin) {
export function clientKey(req) {
return String(req.socket?.remoteAddress || 'local');
}
const IPV4_MAPPED_RE = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
/**
* Whether a socket peer address is this machine: IPv4 loopback in any of its
* forms (`127.0.0.0/8`, including the IPv4-mapped `::ffff:127.x` a dual-stack
* listener reports) or the IPv6 loopback. Non-address keys are not loopback.
* @param {string} address - A `clientKey` value.
* @returns {boolean}
*/
export function isLoopbackPeer(address) {
const raw = String(address || '');
const v4 = IPV4_MAPPED_RE.exec(raw)?.[1] ?? raw;
return v4.startsWith('127.') || raw === '::1';
}
/**
* Rate-limit key that survives address rotation, for limiters whose global
* backstop everyone shares. IPv6 privacy extensions hand one host a fresh
* address at will inside its /64, so keying by the full address lets a single
* host mint per-peer quota until the global window closes for everyone else.
* IPv6 peers are keyed by their /64 prefix instead, and an IPv4-mapped
* address folds to its IPv4 form so one host cannot hold two buckets. Any
* other value (IPv4, or the `local` fallback) passes through unchanged.
* @param {import('node:http').IncomingMessage} req
* @returns {string}
*/
export function clientNetworkKey(req) {
const address = clientKey(req);
const mapped = IPV4_MAPPED_RE.exec(address);
if (mapped) return mapped[1];
const bare = address.replace(/%.*$/, ''); // link-local zone id
if (!isIPv6(bare)) return address;
const [head, tail = ''] = bare.split('::');
const headParts = head ? head.split(':') : [];
const tailParts = tail ? tail.split(':') : [];
const missing = Math.max(0, 8 - headParts.length - tailParts.length);
const hextets = [...headParts, ...Array(missing).fill('0'), ...tailParts];
return `${hextets
.slice(0, 4)
.map((h) => h.toLowerCase().replace(/^0+(?=.)/, ''))
.join(':')}::/64`;
}
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { clientNetworkKey, isLoopbackPeer } from './rate-limit.js';
const req = (remoteAddress) => ({ socket: { remoteAddress } });
test('clientNetworkKey keys IPv6 peers by /64 and folds IPv4-mapped peers', () => {
assert.equal(clientNetworkKey(req('2001:db8:1:2::1')), '2001:db8:1:2::/64');
assert.equal(
clientNetworkKey(req('2001:0db8:0001:0002:abcd:ef01:2345:6789')),
'2001:db8:1:2::/64',
);
assert.equal(clientNetworkKey(req('2001:DB8::1')), '2001:db8:0:0::/64');
assert.equal(clientNetworkKey(req('fe80::1%en0')), 'fe80:0:0:0::/64');
assert.equal(clientNetworkKey(req('::1')), '0:0:0:0::/64');
assert.equal(clientNetworkKey(req('::ffff:10.0.0.5')), '10.0.0.5');
assert.equal(clientNetworkKey(req('10.0.0.5')), '10.0.0.5');
assert.equal(clientNetworkKey(req(undefined)), 'local');
assert.equal(clientNetworkKey(req('not-an-address')), 'not-an-address');
});
test('isLoopbackPeer recognizes every loopback form and nothing else', () => {
for (const address of ['127.0.0.1', '127.9.9.9', '::1', '::ffff:127.0.0.1']) {
assert.equal(isLoopbackPeer(address), true, address);
}
for (const address of [
'10.0.0.1',
'::ffff:10.0.0.1',
'2001:db8::1',
'::2',
'local',
'',
undefined,
]) {
assert.equal(isLoopbackPeer(address), false, String(address));
}
});
+1 -1
View File
@@ -58,7 +58,7 @@ function forwardedProtocol(req) {
/**
* Cross-site gate for keyless routes the map needs in order to render
* (`/api/terrain/heights`). It refuses the same cross-site browser shapes as
* (`/api/terrain/heights`, `/api/overpass`). It refuses the same cross-site browser shapes as
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
* those protect credentials and spend, which a keyless route has neither of,
+69 -13
View File
@@ -1,5 +1,12 @@
import { SNAPSHOT_FEED_STATES } from '../../../src/data/layerSnapshot.js';
import {
HUD_SUMMARY_INSTRUCTIONS,
HUD_SUMMARY_MAX_CHARS,
HUD_SUMMARY_MAX_UNSPACED_CHARS,
HUD_SUMMARY_RESPONSE_FORMAT,
hudSummaryInput,
hudSummaryInstructions,
hudSummaryIsUnspaced,
hudSummaryLocale,
keylessHudSummaryResponse,
} from '../../../src/hudSummaryResponse.js';
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
@@ -18,14 +25,58 @@ function extractOpenAiResponseText(data) {
.trim();
}
function toFiveWordHudSummary(value) {
return String(value || '')
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
.trim()
/** Parse the summary and normalize the canonical feed state. */
function parseHudSummaryReply(text, locale) {
let reply = null;
try {
reply = JSON.parse(text);
} catch {
/* not JSON: no summary */
}
const feedState = SNAPSHOT_FEED_STATES.includes(reply?.feedState)
? reply.feedState
: null;
return {
summary: toHudSummary(reply?.summary, locale),
feedState,
};
}
function toHudSummary(value, requested) {
const locale = hudSummaryLocale(requested);
const words = String(value || '')
.normalize('NFC')
// Marks, and apostrophes and dots inside a word, belong to it: ar vowel
// signs, fr d'Orléans, ja サンフランシスコ・ベイエリア.
.replace(/[^\p{L}\p{M}\p{N}\s'’·・-]/gu, ' ')
.split(/\s+/)
.filter(Boolean)
.slice(0, 5)
.join(' ');
.map((word) => word.replace(/^['’·・-]+|['’·・-]+$/gu, ''))
.filter(Boolean);
// Characters, not words: the model counts Rue de Rivoli as one word.
const max = hudSummaryIsUnspaced(locale)
? HUD_SUMMARY_MAX_UNSPACED_CHARS
: HUD_SUMMARY_MAX_CHARS;
return capAtWord(words.join(' '), max, locale);
}
/**
* Cut `text` to `max` characters at a word boundary; a first word longer
* than that is cut where the cap falls.
*/
function capAtWord(text, max, locale) {
const graphemes = [
...new Intl.Segmenter(locale, { granularity: 'grapheme' }).segment(text),
];
if (graphemes.length <= max) return text;
const cut = graphemes[max].index;
let end = 0;
for (const { index, segment } of new Intl.Segmenter(locale, {
granularity: 'word',
}).segment(text)) {
if (index + segment.length > cut) break;
end = index + segment.length;
}
return text.slice(0, end).trim() || text.slice(0, cut);
}
async function handleHudSummary(req, res) {
@@ -77,14 +128,18 @@ async function handleHudSummary(req, res) {
model:
process.env.OPENAI_HUD_SUMMARY_MODEL ||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
instructions: HUD_SUMMARY_INSTRUCTIONS,
input: JSON.stringify(context),
instructions: hudSummaryInstructions(context?.locale),
input: hudSummaryInput(context),
text: { format: HUD_SUMMARY_RESPONSE_FORMAT },
reasoning: { effort: 'minimal' },
max_output_tokens: 100,
max_output_tokens: 200,
}),
});
const data = await response.json().catch(() => ({}));
const summary = toFiveWordHudSummary(extractOpenAiResponseText(data));
const { summary, feedState } = parseHudSummaryReply(
extractOpenAiResponseText(data),
context?.locale,
);
res.statusCode = response.ok && summary ? 200 : response.status || 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
@@ -93,6 +148,7 @@ async function handleHudSummary(req, res) {
res.end(
JSON.stringify({
summary: summary || null,
feedState,
// Never relay `data.error.message`: that is OpenAI's own wording, and
// it carries request ids, organization hints and quota phrasing.
error: response.ok ? null : 'OpenAI HUD summary request failed',
@@ -110,4 +166,4 @@ async function handleHudSummary(req, res) {
}
}
export { handleHudSummary };
export { handleHudSummary, parseHudSummaryReply };
+58 -9
View File
@@ -1,6 +1,12 @@
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import {
makeRateLimiter,
clientKey,
clientNetworkKey,
isLoopbackPeer,
} from './common/rate-limit.js';
import { hasProxySignals } from '../../src/localRequestGate.mjs';
import { readRequestBodyCapped } from './common/request.js';
import { admitSameSite } from './common/same-site.js';
import { admitSameSiteKeyless } from './common/same-site.js';
import {
OVERPASS_MAX_BODY_BYTES,
OVERPASS_MAX_CONCURRENT,
@@ -29,10 +35,21 @@ const _overpassInFlight = new Map();
let _overpassConcurrent = 0;
// Bound body-reading and validation work, including malformed requests and
// cache hits. Keep the tighter upstream budget below independent of this gate.
const _overpassRequestLimiter = makeRateLimiter({
windowMs: 60_000,
max: 300,
// cache hits, before a byte is read. Remote peers share one global window so
// address rotation cannot mint admission without bound (IPv6 peers are keyed
// by /64 for the same reason). A direct loopback request — the owner's own
// browser and the repo QA harnesses, with no forwarding headers — has its own
// per-peer window outside that global, so busy LAN peers cannot lock the owner
// out of the map. A request relayed by a reverse proxy on this machine also
// arrives from loopback, but it is remote traffic: it is admitted from the
// remote pool under its own socket-keyed bucket, never from the owner's. The
// forwarded client address is not trusted as identity. Only admission is
// isolated: the upstream budget below (90/min per key, 300/min globally) is
// shared, so an owner request that misses cache can still be refused there.
const OVERPASS_ADMISSION_WINDOW = { windowMs: 60_000, max: 300 };
const _overpassLoopbackAdmission = makeRateLimiter(OVERPASS_ADMISSION_WINDOW);
const _overpassPeerAdmission = makeRateLimiter({
...OVERPASS_ADMISSION_WINDOW,
globalMax: 900,
});
@@ -42,6 +59,33 @@ const _overpassRateLimiter = makeRateLimiter({
globalMax: 300,
});
/**
* Classify a request for admission. Direct loopback — a loopback socket with
* no forwarding header — is the owner's own pool; everything else, including a
* loopback socket that carries forwarding headers (a local reverse proxy), is
* remote. A proxied request keeps its socket identity under a `proxied:`
* prefix, so it never shares a bucket with the owner's direct requests.
* @param {import('http').IncomingMessage} req
* @returns {{direct: boolean, key: string}}
*/
function overpassAdmissionClass(req) {
const peer = clientNetworkKey(req);
if (!isLoopbackPeer(clientKey(req))) return { direct: false, key: peer };
if (hasProxySignals(req.headers || {}))
return { direct: false, key: `proxied:${peer}` };
return { direct: true, key: peer };
}
/**
* Spend one admission slot for this request; false when its bucket is over its
* window (or, for remote traffic, when the shared global window is full).
* @param {{direct: boolean, key: string}} admission - `overpassAdmissionClass(req)`.
* @returns {boolean}
*/
function admitOverpassRequest({ direct, key }) {
return (direct ? _overpassLoopbackAdmission : _overpassPeerAdmission)(key);
}
/**
* Write a completed Overpass payload to the HTTP response.
*
@@ -103,8 +147,13 @@ function overpassProxy({ routing = {} } = {}) {
return;
}
if (admitSameSite(req, res)) return;
if (!_overpassRequestLimiter(clientKey(req))) {
// Keyless gate: refuses cross-site browser requests but, unlike the
// cost-bearing routes, admits the forwarding headers a reverse proxy
// adds — the layers that depend on Overpass must keep working for an
// instance deployed the way the README documents.
if (admitSameSiteKeyless(req, res)) return;
const admission = overpassAdmissionClass(req);
if (!admitOverpassRequest(admission)) {
res.writeHead(429, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
@@ -163,7 +212,7 @@ function overpassProxy({ routing = {} } = {}) {
// requests; boundary-class queries keep their month-long TTL.
readDisk: () =>
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
allowUpstream: () => _overpassRateLimiter(admission.key),
loadUpstream: async () => {
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT)
return {
+10 -6
View File
@@ -122,12 +122,14 @@ function sanitizeOverpassBody(rawBody) {
const boundedSets = new Set();
for (let stmt of withoutTags.split(';')) {
stmt = stmt.trim();
if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue;
// The settings block (`[out:json][timeout:25]`) was blanked together with
// the tag filters, so it arrives here as an empty statement.
if (!stmt || /^out\b/.test(stmt)) continue;
// Strip output-set assignments (NOT input bounds). Bracket tag filters were
// removed above so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot be
// misread as a spatial bound. Bounds live in (...) / function calls / set
// refs, never inside [...], so the probe loses nothing real.
// Strip output-set assignments (NOT input bounds). With the tag filters
// already gone, a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot
// be misread as a spatial bound; bounds live in (...) / function calls /
// set refs, never inside [...], so nothing real was lost.
const outSets = [];
const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
outSets.push(name);
@@ -166,8 +168,10 @@ function sanitizeOverpassBody(rawBody) {
if (bounded) for (const name of outSets) boundedSets.add(name);
}
// Tolerate whitespace around the directive so `[ timeout : N ]` is clamped
// rather than passed through as written.
const clamped = data.replace(
/\[timeout:\s*(\d+)\s*\]/gi,
/\[\s*timeout\s*:\s*(\d+)\s*\]/gi,
(_, n) =>
`[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`,
);
@@ -3,10 +3,24 @@ import assert from 'node:assert/strict';
import { Readable } from 'node:stream';
import { spawnSync } from 'node:child_process';
import { overpassProxy } from '../overpass.js';
import { _overpassCache } from './cache.js';
import { sanitizeOverpassBody } from './query.js';
const form = (query) => `data=${encodeURIComponent(query)}`;
// The limiters are module-level, so every test that spends admission mocks a
// clock a full day past the previous one in execution order: no test can see
// another's window, whatever order (or randomization) they run in. A new
// test that reaches admission must call it too: earlier tests leave
// timestamps days ahead of the real clock, and those still count.
let clockBase = Date.now() + 86_400_000;
function freshClock(t) {
clockBase += 86_400_000;
const clock = { now: clockBase };
t.mock.method(Date, 'now', () => clock.now);
return clock;
}
test('bracket validation rejects malformed filters without trusting their contents as bounds', () => {
for (const query of [
'node[',
@@ -37,6 +51,15 @@ test('bounded queries preserve quoted brackets, comments, multiple tags and time
query.replace('[timeout:999]', '[timeout:30]'),
);
}
// Whitespace inside the directive is clamped too, not passed through.
const spaced = sanitizeOverpassBody(
form('[out:json][ timeout : 999 ];node(around:20,1,1);out;'),
);
assert.equal(spaced.ok, true);
assert.equal(
new URLSearchParams(spaced.body).get('data'),
'[out:json][timeout:30];node(around:20,1,1);out;',
);
});
test('a burst of maximum-size unterminated filters completes within a bounded process deadline', () => {
@@ -126,7 +149,13 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
{ 'sec-fetch-site': 'cross-site' },
{ 'sec-fetch-site': 'same-site' },
{ origin: 'null' },
{ 'x-forwarded-for': '127.0.0.1' },
// Forwarding headers do not launder a cross-site request.
{ 'x-forwarded-for': '10.0.0.9', 'sec-fetch-site': 'cross-site' },
{
'x-forwarded-proto': 'https',
origin: 'http://localhost:4173',
'sec-fetch-site': 'same-origin',
},
]) {
const response = await call(route, { headers, forbidRead: true });
assert.equal(response.statusCode, 403);
@@ -136,12 +165,24 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
});
}
test('same-origin, LAN and non-browser requests still reach validation; GET status stays available', async () => {
test('same-origin, LAN, proxied and non-browser requests still reach validation; GET status stays available', async (t) => {
freshClock(t);
const route = handler();
for (const headers of [
{},
{ origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' },
{ host: '192.168.1.10:4173', origin: 'http://192.168.1.10:4173' },
// The README's reverse-proxy deployment: Overpass is keyless, so the
// forwarding headers the proxy adds are admitted (as for terrain heights).
{
host: 'globe.example.lan',
origin: 'https://globe.example.lan',
'sec-fetch-site': 'same-origin',
'x-forwarded-for': '203.0.113.7',
'x-forwarded-proto': 'https',
'x-forwarded-host': 'globe.example.lan',
},
{ 'x-forwarded-for': '127.0.0.1' },
]) {
const response = await call(route, { headers });
assert.equal(response.statusCode, 400);
@@ -158,8 +199,7 @@ test('same-origin, LAN and non-browser requests still reach validation; GET stat
});
test('invalid requests spend admission quota before parsing, including on an unconfigured server', async (t) => {
let now = Date.now() + 120000;
t.mock.method(Date, 'now', () => now);
const clock = freshClock(t);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
@@ -173,6 +213,216 @@ test('invalid requests spend admission quota before parsing, including on an unc
assert.equal(denied.reads, 0);
assert.equal(denied.headers['retry-after'], '60');
assert.equal((await call(route, { peer: 'other-peer' })).statusCode, 400);
now += 60001;
clock.now += 60_001;
assert.equal((await call(route, { peer })).statusCode, 400);
});
test('the 900/min global window spans non-loopback peers only; loopback keeps its own window', async (t) => {
const clock = freshClock(t);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
const route = handler();
for (const peer of ['10.0.0.1', '10.0.0.2', '10.0.0.3']) {
for (let i = 0; i < 300; i++) {
assert.equal((await call(route, { peer })).statusCode, 400, peer);
}
}
const starved = await call(route, { peer: '10.0.0.4', forbidRead: true });
assert.equal(starved.statusCode, 429, 'global window is full for LAN peers');
assert.equal(starved.reads, 0);
for (const peer of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) {
const owner = await call(route, { peer });
assert.equal(owner.statusCode, 400, `${peer} is admitted past the global`);
assert.equal(owner.reads, 1);
}
clock.now += 60_001;
assert.equal((await call(route, { peer: '10.0.0.4' })).statusCode, 400);
});
test('IPv6 peers share a /64 admission bucket and IPv4-mapped peers fold to IPv4', async (t) => {
const clock = freshClock(t);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
const route = handler();
for (let i = 0; i < 300; i++) {
assert.equal(
(await call(route, { peer: `2001:db8:1:2::${(i % 200) + 1}` }))
.statusCode,
400,
);
}
const rotated = await call(route, {
peer: '2001:db8:1:2:ffff:ffff:ffff:9',
forbidRead: true,
});
assert.equal(rotated.statusCode, 429, 'a rotating host stays in one bucket');
assert.equal(
(await call(route, { peer: '2001:db8:1:3::1' })).statusCode,
400,
);
for (let i = 0; i < 300; i++) {
assert.equal(
(await call(route, { peer: '::ffff:10.1.1.1' })).statusCode,
400,
);
}
const unmapped = await call(route, { peer: '10.1.1.1', forbidRead: true });
assert.equal(unmapped.statusCode, 429, 'mapped and bare IPv4 share a bucket');
clock.now += 60_001;
assert.equal((await call(route, { peer: '10.1.1.1' })).statusCode, 400);
});
test('cache hits spend admission budget but not the 90/min upstream budget', async (t) => {
const clock = freshClock(t);
const prior = process.env.OVERPASS_UPSTREAMS;
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
t.after(() => {
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
else process.env.OVERPASS_UPSTREAMS = prior;
_overpassCache.delete(hitKey);
});
let fetches = 0;
t.mock.method(globalThis, 'fetch', async () => {
fetches++;
// Not data: nothing is cached or written to disk by this test.
return new Response('upstream unavailable', { status: 503 });
});
const hit = 'node(around:20,1,1);out;';
const hitKey = `data=${encodeURIComponent(hit)}`;
_overpassCache.set(hitKey, {
status: 200,
body: '{"elements":[]}',
contentType: 'application/json',
endpoint: 'https://overpass.example/api/interpreter',
cachedAt: clock.now,
});
const route = handler();
const peer = '10.7.7.7';
// More cache hits than the upstream limiter would allow per peer.
for (let i = 0; i < 100; i++) {
const response = await call(route, { peer, body: form(hit) });
assert.equal(response.statusCode, 200);
assert.equal(response.headers['x-overpass-cache'], 'HIT');
}
assert.equal(fetches, 0);
const miss = await call(route, {
peer,
body: form('node(around:20,2,2);out;'),
});
assert.equal(fetches, 1, 'the first miss still reaches upstream');
assert.notEqual(miss.statusCode, 429);
for (let i = 0; i < 199; i++) {
assert.equal(
(await call(route, { peer, body: form(hit) })).statusCode,
200,
);
}
const exhausted = await call(route, {
peer,
body: form(hit),
forbidRead: true,
});
assert.equal(exhausted.statusCode, 429, 'hits spent the admission window');
assert.equal(exhausted.reads, 0);
});
test('a local reverse proxy cannot spend the direct-loopback reserve; owner cache hits survive it', async (t) => {
const clock = freshClock(t);
const prior = process.env.OVERPASS_UPSTREAMS;
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
const hit = 'node(around:20,3,3);out;';
const hitKey = `data=${encodeURIComponent(hit)}`;
t.after(() => {
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
else process.env.OVERPASS_UPSTREAMS = prior;
_overpassCache.delete(hitKey);
});
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
_overpassCache.set(hitKey, {
status: 200,
body: '{"elements":[]}',
contentType: 'application/json',
endpoint: 'https://overpass.example/api/interpreter',
cachedAt: clock.now,
});
const route = handler();
// The proxy connects over loopback; a rotating forwarded client address
// must not mint fresh buckets, so all 300 land in one socket-keyed bucket.
for (const proxyPeer of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) {
const proxied = (i) => ({
peer: proxyPeer,
headers: { 'x-forwarded-for': `203.0.113.${i % 250}` },
body: form(hit),
});
for (let i = 0; i < 300; i++) {
assert.equal((await call(route, proxied(i))).statusCode, 200, proxyPeer);
}
const refused = await call(route, { ...proxied(300), forbidRead: true });
assert.equal(refused.statusCode, 429, `${proxyPeer} proxy bucket is full`);
assert.equal(refused.reads, 0);
for (const peer of ['127.0.0.1', '::1']) {
const owner = await call(route, { peer, body: form(hit) });
assert.equal(owner.statusCode, 200, `direct ${peer} is still admitted`);
assert.equal(owner.headers['x-overpass-cache'], 'HIT');
}
clock.now += 60_001;
assert.equal((await call(route, proxied(0))).statusCode, 200);
}
});
test('proxied loopback traffic draws on the shared remote global window', async (t) => {
const clock = freshClock(t);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
const route = handler();
for (const peer of ['10.0.0.1', '10.0.0.2']) {
for (let i = 0; i < 300; i++) {
assert.equal((await call(route, { peer })).statusCode, 400, peer);
}
}
for (let i = 0; i < 300; i++) {
assert.equal(
(await call(route, { headers: { via: '1.1 proxy' } })).statusCode,
400,
);
}
const starved = await call(route, { peer: '10.0.0.3', forbidRead: true });
assert.equal(starved.statusCode, 429, 'proxied requests counted globally');
assert.equal((await call(route, {})).statusCode, 400, 'owner unaffected');
});
test("a local proxy has its own upstream bucket, so its misses cannot spend the owner's", async (t) => {
freshClock(t);
const prior = process.env.OVERPASS_UPSTREAMS;
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
t.after(() => {
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
else process.env.OVERPASS_UPSTREAMS = prior;
});
// Not data: nothing is cached, so every request below is a miss.
t.mock.method(
globalThis,
'fetch',
async () => new Response('upstream unavailable', { status: 503 }),
);
const route = handler();
const miss = form('node(around:20,4,4);out;');
// The upstream limiter's own refusal is the only miss without this header.
const passedLimiter = (response) =>
response.headers['x-overpass-cache'] !== undefined;
const proxied = { peer: '127.0.0.1', headers: { via: '1.1 proxy' } };
for (let i = 0; i < 90; i++) {
const response = await call(route, { ...proxied, body: miss });
assert.equal(passedLimiter(response), true, `proxied miss ${i}`);
}
const spent = await call(route, { ...proxied, body: miss });
assert.equal(spent.statusCode, 429);
assert.equal(passedLimiter(spent), false, 'the proxy bucket is spent');
const owner = await call(route, { peer: '127.0.0.1', body: miss });
assert.equal(passedLimiter(owner), true, 'the owner keeps its own bucket');
});
+2
View File
@@ -29,6 +29,7 @@ import { createApplicationFirePerimeters } from './layers/perimeters.js';
import { createApplicationGnssIntegrity } from './layers/gnss.js';
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
import { localGeoJsonServices } from './localGeojsonServices.js';
@@ -169,6 +170,7 @@ export function createApplicationCatalog({
clock: weatherClock,
}),
createCyclonesLayer({ feed: sources.cyclones }),
...createOsmOverlayLayers(),
...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }),
createApplicationFirms({
+2 -2
View File
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 33);
assert.equal(first.layers.length, 35);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
@@ -155,7 +155,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
surface: fixtureSurface(lifetime.signal),
};
const catalog = createApplicationCatalog(options);
assert.equal(catalog.layers.length, 33);
assert.equal(catalog.layers.length, 35);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
+1 -1
View File
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
assert.equal(actual.layers.length, 33);
assert.equal(actual.layers.length, 35);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
+14
View File
@@ -150,6 +150,20 @@ export const DATA_CREDITS = [
key: 'usgs',
html: 'Earthquakes: Data courtesy of the U.S. Geological Survey',
},
{
key: 'openseamap',
html:
'Sea marks overlay: ' +
'<a href="https://www.openseamap.org" target="_blank" rel="noopener">© OpenSeaMap contributors</a> (CC BY-SA 2.0) · ' +
'Data © OpenStreetMap contributors (ODbL)',
},
{
key: 'opensnowmap',
html:
'Ski pistes overlay: ' +
'<a href="https://www.opensnowmap.org" target="_blank" rel="noopener">© www.opensnowmap.org</a> (CC BY-SA) · ' +
'Data © OpenStreetMap contributors (ODbL)',
},
{
key: 'nasa-gibs',
html:
+68 -3
View File
@@ -17,6 +17,7 @@ export const LAYER_FEED_STATES = Object.freeze([
'loading',
'degraded',
'stale',
'partial',
'fallback',
'unavailable',
]);
@@ -27,6 +28,7 @@ export const LAYER_FEED_STATE_LABELS = Object.freeze({
loading: 'LOADING',
degraded: 'DEGRADED',
stale: 'STALE',
partial: 'PARTIAL',
fallback: 'FALLBACK',
unavailable: 'UNAVAILABLE',
});
@@ -48,8 +50,9 @@ export const FEED_STATE_SEVERITY = Object.freeze({
degraded: 2,
stale: 3,
fallback: 4,
nominal: 5,
off: 6,
partial: 5,
nominal: 6,
off: 7,
});
export { layerFeedState } from './feedState.js';
@@ -90,7 +93,7 @@ export function snapshotAgeLabel(lastUpdate, now = Date.now()) {
/**
* Pick the most severe feed-state from a list.
* @param {Iterable<string|null|undefined>} states Feed-state strings.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'|'off'|null}
* @returns {'nominal'|'loading'|'degraded'|'stale'|'partial'|'fallback'|'unavailable'|'off'|null}
*/
export function worstFeedState(states) {
let worst = null;
@@ -205,6 +208,8 @@ export function feedProvenanceNote(layers = [], overall = null) {
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
});
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
if (overall === 'partial')
return `${body}. Narrate that coverage is incomplete with any count; do not claim full coverage.`;
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
}
@@ -251,3 +256,63 @@ export function viewStateLayerRow(layer, options) {
lastUpdate: snap.lastUpdate,
};
}
/**
* The feed state and up to two layer names of a provenance envelope that is
* not nominal, or null; the worst layers come first, so the names match the
* state. Values stay English (`state` is a feed-state id, `names` are
* `layer.name`); the HUD translates them for display.
* @param {{ overall?: string|null, layers?: Array<object> }|null} envelope
* @returns {{ state: string, names: string[] }|null}
*/
export function provenanceTag(envelope) {
const overall = envelope?.overall;
if (!overall || overall === 'nominal' || overall === 'off') return null;
const names = (envelope.layers || [])
.filter(
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
)
.sort(
(a, b) =>
FEED_STATE_SEVERITY[a.feedState] - FEED_STATE_SEVERITY[b.feedState],
)
.map((s) => String(s.name || s.id || 'LAYER'))
.slice(0, 2);
return { state: overall, names };
}
const upperCase = (word) => word.toUpperCase();
/**
* `STATE | text | LAYERS`: the state leads so a narrow readout never clips
* it; the layer names trail so they never push out the text.
* @param {string} text
* @param {{ state: string, names: string[] }|null} tag From `provenanceTag`.
* @param {(state: string) => string} [stateLabel]
* @param {(name: string) => string} [layerLabel]
* @returns {string}
*/
export function provenanceLine(
text,
tag,
stateLabel = upperCase,
layerLabel = upperCase,
) {
if (!tag) return text;
return [stateLabel(tag.state), text, tag.names.map(layerLabel).join('/')]
.filter(Boolean)
.join(' | ');
}
/**
* Require a matching feedState for warning states: a view that is not
* nominal falls back on a missing or different state.
* @param {{ feedState?: string|null }|null} response The proxy's reply.
* @param {{ overall?: string|null }|null} provenance The context's envelope.
* @returns {boolean}
*/
export function hudSummaryMatchesProvenance(response, provenance) {
const state = provenance?.overall;
if (!state || state === 'nominal' || state === 'off') return true;
return response?.feedState === state;
}
+52 -1
View File
@@ -11,6 +11,7 @@ import {
layerFeedState,
layerSnapshot,
layerSnapshots,
provenanceTag,
snapshotAgeLabel,
snapshotAgeSec,
viewStateLayerRow,
@@ -90,7 +91,15 @@ test('layerFeedState matches the chip classifier for every honesty class', () =>
test('chip labels and severity cover every feed-state the envelope can emit', () => {
assert.deepEqual(
[...LAYER_FEED_STATES],
['nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable'],
[
'nominal',
'loading',
'degraded',
'stale',
'partial',
'fallback',
'unavailable',
],
);
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
for (const state of LAYER_FEED_STATES) {
@@ -107,6 +116,33 @@ test('chip labels and severity cover every feed-state the envelope can emit', ()
assert.equal(worstFeedState([]), null);
});
test('partial is a feed-state of its own: above nominal, below every fault', () => {
const layer = (id, stats) => ({ id, name: id, enabled: true, stats });
const partial = layer('flights', {
partial: true,
count: 4,
lastUpdate: NOW,
});
const nominal = layer('vessels', { count: 9, lastUpdate: NOW });
const stale = layer('cctv', { stale: true, count: 2, lastUpdate: NOW });
const overall = (layers) =>
feedProvenanceEnvelope(layers, { now: NOW }).overall;
assert.equal(overall([partial]), 'partial');
assert.equal(overall([partial, nominal]), 'partial');
assert.equal(overall([nominal, partial]), 'partial');
assert.equal(overall([partial, stale]), 'stale');
assert.equal(overall([stale, partial]), 'stale');
assert.equal(LAYER_FEED_STATE_LABELS.partial, 'PARTIAL');
const note = feedProvenanceEnvelope([partial], { now: NOW }).note;
assert.match(note, /coverage is incomplete/);
assert.doesNotMatch(note, /do not describe this as live/);
assert.equal(
FEED_STATE_SEVERITY.fallback < FEED_STATE_SEVERITY.partial &&
FEED_STATE_SEVERITY.partial < FEED_STATE_SEVERITY.nominal,
true,
);
});
test('a disabled layer is off even when leftover stats would read stale', () => {
const snap = layerSnapshot(
{
@@ -258,3 +294,18 @@ test('age labels match the Data Layers meta clock', () => {
assert.equal(snapshotAgeLabel(NOW - 240_000, NOW), '4m ago');
assert.equal(snapshotAgeLabel(NOW - 7_200_000, NOW), '2h ago');
});
test('the warning tag names the layers in the reported state first', () => {
const tag = provenanceTag({
overall: 'unavailable',
layers: [
{ name: 'Live Flights', feedState: 'partial' },
{ name: 'Live AIS Vessels', feedState: 'partial' },
{ name: 'CCTV', feedState: 'unavailable' },
],
});
assert.deepEqual(tag, {
state: 'unavailable',
names: ['CCTV', 'Live Flights'],
});
});
+2
View File
@@ -666,6 +666,8 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options',
optionOwner: 'satellites',
}),
Object.freeze({ id: 'sea-marks', token: '6', disposition: 'enabled-only' }),
Object.freeze({ id: 'ski-pistes', token: '7', disposition: 'enabled-only' }),
Object.freeze({
id: 'street-level',
token: '0',
+13 -15
View File
@@ -202,8 +202,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 32);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 32);
assert.equal(REGISTERED_LAYER_IDS.length, 34);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 34);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -242,20 +242,18 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
}
assert.equal(nextLayerStateToken(), '6');
assert.equal(nextLayerStateToken(), '8');
assert.equal(
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '6',
bravo: '7',
alpha: '8',
bravo: '9',
}),
'8',
'00',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries(
[...'6789'].map((digit) => [`prior-${digit}`, digit]),
),
...Object.fromEntries([...'89'].map((digit) => [`prior-${digit}`, digit])),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
assert.equal(
@@ -307,8 +305,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '6',
next: '7',
future: '8',
next: '9',
}),
true,
);
@@ -318,7 +316,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 6/,
/next free token 8/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
@@ -341,10 +339,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6', competing: '6' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8', competing: '8' },
),
/next free token 7/,
/next free token 9/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
+30 -28
View File
@@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 32);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 34);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{
@@ -137,6 +137,8 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
'gnss-integrity': '3',
'air-quality': '4',
's2-ghostmaps': '5',
'sea-marks': '6',
'ski-pistes': '7',
},
);
assert.throws(
@@ -162,9 +164,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '6']])
parseLayerStateTokenReservations([...reservationRows, ['future', '8']])
.future,
'6',
'8',
);
assert.throws(
() =>
@@ -182,14 +184,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '6']] },
{ rows: [...reservationRows, ['retired-layer', '8']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '7');
assert.equal(nextLayerStateToken(published), '9');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '7',
newcomer: '9',
}),
true,
);
@@ -197,15 +199,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
competing: '6',
competing: '8',
}),
/next free token 7/,
/next free token 9/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '6',
newcomer: '8',
}),
/changed or removed: retired-layer/,
);
@@ -213,8 +215,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '6']] }, (cwd) => {
test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '8']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -235,8 +237,8 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '6']],
[{ id: 'pr-b', token: '6' }],
[...reservationRows, ['pr-b', '8']],
[{ id: 'pr-b', token: '8' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -244,10 +246,10 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '6'], ['pr-b', '6']],
[...reservationRows, ['pr-a', '8'], ['pr-b', '8']],
[
{ id: 'pr-a', token: '6' },
{ id: 'pr-b', token: '6' },
{ id: 'pr-a', token: '8' },
{ id: 'pr-b', token: '8' },
],
);
const staleRebase = runCheck();
@@ -255,16 +257,16 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '7');
const validBaselineRows = [...reservationRows, ['pr-a', '6']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '6' }]);
assert.equal(nextLayerStateToken(published), '9');
const validBaselineRows = [...reservationRows, ['pr-a', '8']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '8' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 7/);
assert.match(helper.stdout, /pr-b: 9/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -272,20 +274,20 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '6'], ['pr-b', '7']],
[...reservationRows, ['pr-a', '8'], ['pr-b', '9']],
[
{ id: 'pr-a', token: '6' },
{ id: 'pr-b', token: '7' },
{ id: 'pr-a', token: '8' },
{ id: 'pr-b', token: '9' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /33 published, 1 new/);
assert.match(corrected.stdout, /35 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'6789'].map((digit) => [`retired-${digit}`, digit]);
const digitRows = [...'89'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
@@ -337,7 +339,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try {
const priorDigits = [...'6789'].map((digit) => [
const priorDigits = [...'89'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -374,7 +376,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
assert.equal(params.get('l').length, 75);
assert.equal(params.get('l').length, 79);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -392,7 +394,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 33);
assert.equal(Object.keys(published).length, 35);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
@@ -22,6 +22,8 @@
["rocket-launches", "x"],
["s2-ghostmaps", "5"],
["satellites", "s"],
["sea-marks", "6"],
["ski-pistes", "7"],
["street-level", "0"],
["telegeography-submarine-cables", "u"],
["traffic", "t"],
+190
View File
@@ -0,0 +1,190 @@
import * as Cesium from 'cesium';
import { NO_IMAGERY_HOST, resolveImageryHost } from '../maps/imageryHost.js';
/**
* @file Open-data raster overlays: OpenSeaMap sea marks and OpenSnowMap ski
* pistes (#10). Each overlay is its own layer with its own row under
* Infrastructure; both are built from the descriptors below.
*
* OpenRailwayMap and OpenInfraMap, also named in #10, are deliberately not
* included:
* - OpenRailwayMap's public tile server (tiles.openrailwaymap.org) only
* serves requests whose Referer is openrailwaymap.org; every other Referer,
* including none, gets 403. A tab on this app sends its own origin, so the
* tiles would fail, and embedding them would run against their tile-usage
* policy either way.
* - OpenInfraMap serves MapLibre vector tiles, not raster XYZ tiles. That
* needs a vector-tile rendering pipeline, not an imagery-layer drop-in.
*
* The overlays drape on whatever surface the active map stack offers (see
* `maps/imageryHost.js`): the globe on the OSM and Bing stacks, the Google
* Photorealistic tileset's own `imageryLayers` on Google 3D. Only a stack with
* neither leaves them hidden, and the row says so.
* @module data/osmOverlays
*/
/** @type {ReadonlyArray<{id:string,name:string,icon:string,source:string,urlTemplate:string,credit:string,maximumLevel:number}>} */
export const OSM_OVERLAYS = Object.freeze([
Object.freeze({
id: 'sea-marks',
name: 'Sea Marks',
icon: '⚓',
source: 'OpenSeaMap',
urlTemplate: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
// Chart tiles CC BY-SA 2.0; the underlying data is OpenStreetMap (ODbL).
credit:
'Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)',
maximumLevel: 18,
}),
Object.freeze({
id: 'ski-pistes',
name: 'Ski Pistes',
icon: '⛷',
source: 'OpenSnowMap',
urlTemplate: 'https://www.opensnowmap.org/pistes/{z}/{x}/{y}.png',
// OpenSnowMap asks for "(c)www.opensnowmap.org" wherever its tiles appear.
credit:
'Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)',
maximumLevel: 18,
}),
]);
/** Why an enabled overlay is showing nothing, or null. */
export function osmOverlayHiddenReason(enabled, hostKind) {
return enabled && hostKind === 'none' ? NO_IMAGERY_HOST : null;
}
/**
* Create the layer for one overlay descriptor. Each application catalog gets
* its own instances; the Cesium factories are injectable so tests run without
* WebGL.
*/
export function createOsmOverlayLayer(
overlay,
{
host = resolveImageryHost,
createProvider = (descriptor) =>
new Cesium.UrlTemplateImageryProvider({
url: descriptor.urlTemplate,
credit: descriptor.credit,
maximumLevel: descriptor.maximumLevel,
}),
createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
} = {},
) {
if (!OSM_OVERLAYS.includes(overlay))
throw new TypeError(`Unknown open map overlay: ${overlay?.id}`);
let viewer = null;
let mapStack = null;
let unsubscribeMap = null;
let enabled = false;
let hostInfo = { collection: null, kind: 'none' };
/** The imagery layer this instance owns while enabled, and where it sits. */
let owned = null;
const usable = (collection) => collection && !collection.isDestroyed?.();
function detach(destroy) {
if (!owned) return;
const { collection, layer } = owned;
if (usable(collection) && collection.contains(layer))
collection.remove(layer, destroy);
else if (destroy && !layer.isDestroyed?.()) layer.destroy?.();
owned.collection = null;
if (destroy) owned = null;
}
function resolveHost() {
return (
host({ viewer, tileset: mapStack?.getImageryHostTileset?.() }) || {
collection: null,
kind: 'none',
}
);
}
/** Create, move or drop the owned imagery layer to match the state. */
function sync() {
if (!viewer) return;
hostInfo = enabled ? resolveHost() : { collection: null, kind: 'none' };
if (!enabled) {
detach(true);
} else {
owned ||= {
layer: createImageryLayer(createProvider(overlay)),
collection: null,
};
if (owned.collection !== hostInfo.collection) {
detach(false);
if (usable(hostInfo.collection)) {
// Appended, so it draws above the map stack's base imagery.
hostInfo.collection.add(owned.layer);
owned.collection = hostInfo.collection;
}
}
}
viewer.scene?.requestRender?.();
}
return {
id: overlay.id,
name: overlay.name,
icon: overlay.icon,
source: overlay.source,
updateInterval: 0,
init(nextViewer) {
viewer = nextViewer;
},
/** Follow map-stack switches so the drape moves with the surface. */
attachMapStackController(controller) {
unsubscribeMap?.();
unsubscribeMap = null;
mapStack = controller || null;
const off = controller?.subscribe?.(() => {
if (enabled) sync();
});
unsubscribeMap = typeof off === 'function' ? off : null;
},
enable() {
enabled = true;
sync();
},
disable() {
enabled = false;
sync();
},
update() {
return true;
},
getStats() {
// Without a controller subscription the stats poll doubles as the
// map-stack watch.
if (enabled && !unsubscribeMap) {
const next = resolveHost();
if (next.collection !== hostInfo.collection) sync();
}
return {
count: enabled ? 1 : 0,
error: osmOverlayHiddenReason(enabled, hostInfo.kind),
};
},
/** Owned layer count and host kind, for tests and live gates. */
diagnostics() {
return { host: hostInfo.kind, ownedCount: owned ? 1 : 0 };
},
destroy() {
enabled = false;
detach(true);
unsubscribeMap?.();
unsubscribeMap = null;
mapStack = null;
viewer = null;
hostInfo = { collection: null, kind: 'none' };
},
};
}
/** One layer per overlay, in panel order. */
export function createOsmOverlayLayers(options) {
return OSM_OVERLAYS.map((overlay) => createOsmOverlayLayer(overlay, options));
}
+167
View File
@@ -0,0 +1,167 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
OSM_OVERLAYS,
createOsmOverlayLayer,
createOsmOverlayLayers,
osmOverlayHiddenReason,
} from './osmOverlays.js';
import { NO_IMAGERY_HOST } from '../maps/imageryHost.js';
function fakeCollection() {
const layers = [];
const removed = [];
return {
layers,
removed,
add(layer) {
layers.push(layer);
},
contains(layer) {
return layers.includes(layer);
},
remove(layer, destroy) {
const index = layers.indexOf(layer);
if (index < 0) return false;
layers.splice(index, 1);
removed.push({ layer, destroy });
if (destroy) layer.destroyed = true;
return true;
},
};
}
function fakeMapStack() {
const subscribers = new Set();
return {
tileset: null,
getImageryHostTileset() {
return this.tileset;
},
subscribe(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn);
},
emit() {
for (const fn of subscribers) fn();
},
subscribers,
};
}
function setup(overlay = OSM_OVERLAYS[0]) {
const globe = fakeCollection();
const viewer = {
imageryLayers: globe,
scene: { globe: { show: true }, requestRender() {} },
};
const created = [];
const layer = createOsmOverlayLayer(overlay, {
createProvider: (descriptor) => ({ descriptor }),
createImageryLayer: (provider) => {
const imagery = { provider, destroyed: false };
created.push(imagery);
return imagery;
},
});
const mapStack = fakeMapStack();
layer.init(viewer);
layer.attachMapStackController(mapStack);
return { layer, viewer, globe, created, mapStack };
}
test('OSM_OVERLAYS: the two shipped layers, each with a tile template and credit', () => {
assert.deepEqual(
OSM_OVERLAYS.map((o) => o.id),
['sea-marks', 'ski-pistes'],
);
for (const overlay of OSM_OVERLAYS) {
assert.match(overlay.urlTemplate, /^https:\/\/.*\{z\}.*\{x\}.*\{y\}/);
assert.match(overlay.credit, /OpenStreetMap contributors \(ODbL\)/);
}
assert.match(OSM_OVERLAYS[0].credit, /CC BY-SA 2\.0/);
assert.match(OSM_OVERLAYS[1].credit, /www\.opensnowmap\.org/);
});
test('createOsmOverlayLayers builds one independent layer per overlay', () => {
const layers = createOsmOverlayLayers();
assert.deepEqual(
layers.map((layer) => [layer.id, layer.name]),
[
['sea-marks', 'Sea Marks'],
['ski-pistes', 'Ski Pistes'],
],
);
assert.notEqual(createOsmOverlayLayers()[0], layers[0]);
assert.throws(() => createOsmOverlayLayer({ id: 'other' }), /Unknown/);
});
test('osmOverlayHiddenReason: only when enabled with no imagery host', () => {
assert.equal(osmOverlayHiddenReason(false, 'none'), null);
assert.equal(osmOverlayHiddenReason(true, 'globe'), null);
assert.equal(osmOverlayHiddenReason(true, 'tileset'), null);
assert.equal(osmOverlayHiddenReason(true, 'none'), NO_IMAGERY_HOST);
});
test('a layer owns no imagery until enabled and destroys it on disable', () => {
const { layer, globe, created } = setup(OSM_OVERLAYS[1]);
assert.equal(created.length, 0);
layer.enable();
assert.equal(globe.layers.length, 1);
assert.equal(globe.layers[0].provider.descriptor.id, 'ski-pistes');
assert.deepEqual(layer.getStats(), { count: 1, error: null });
layer.disable();
assert.equal(globe.layers.length, 0);
assert.equal(created[0].destroyed, true);
assert.deepEqual(layer.diagnostics(), { host: 'none', ownedCount: 0 });
assert.deepEqual(layer.getStats(), { count: 0, error: null });
layer.enable();
assert.equal(created.length, 2, 're-enabling creates a fresh imagery layer');
});
test('the overlay follows a map-stack switch onto the Google 3D tileset and back', () => {
const { layer, viewer, globe, mapStack } = setup();
layer.enable();
const [imagery] = globe.layers;
const tileset = { imageryLayers: fakeCollection() };
viewer.scene.globe.show = false;
mapStack.tileset = tileset;
mapStack.emit();
assert.equal(globe.layers.length, 0);
assert.deepEqual(tileset.imageryLayers.layers, [imagery]);
assert.equal(globe.removed[0].destroy, false);
assert.equal(layer.diagnostics().host, 'tileset');
assert.equal(layer.getStats().error, null);
viewer.scene.globe.show = true;
mapStack.tileset = null;
mapStack.emit();
assert.deepEqual(globe.layers, [imagery]);
assert.equal(tileset.imageryLayers.layers.length, 0);
});
test('with no surface to drape on the row explains itself, and recovers when one appears', () => {
const { layer, viewer, globe, mapStack } = setup();
viewer.scene.globe.show = false;
layer.enable();
assert.equal(globe.layers.length, 0);
assert.equal(layer.getStats().error, NO_IMAGERY_HOST);
viewer.scene.globe.show = true;
mapStack.emit();
assert.equal(globe.layers.length, 1);
assert.equal(layer.getStats().error, null);
});
test('destroy removes owned imagery and unsubscribes from the map stack', () => {
const { layer, globe, created, mapStack } = setup();
layer.enable();
layer.destroy();
assert.equal(globe.layers.length, 0);
assert.equal(created[0].destroyed, true);
assert.equal(mapStack.subscribers.size, 0);
});
+16 -75
View File
@@ -51,6 +51,7 @@ import {
radioViewIsGlobal,
stationMatchesRadioCategory,
} from './radio.js';
import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs';
const stations = [
{ id: 'news', tags: ['News', 'Weather Radio', 'air traffic'] },
@@ -883,88 +884,33 @@ test('tuner drag keeps one immutable catalog resolution through refresh and rele
});
test('failed exact tuner release cannot consume a stale playback fallback', async () => {
const originalFetch = globalThis.fetch;
const originalAudio = globalThis.Audio;
const playUrls = [];
const stationRows = [
{
radioStationRow({
id: '00000000-0000-4000-8000-000000000071',
name: 'Exact target',
lat: 30,
lon: -97,
streamUrl: 'https://radio.example.com/exact.mp3',
homepage: null,
tags: ['news'],
languages: ['English'],
state: 'Texas',
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: 'MP3',
bitrate: 128,
},
{
}),
radioStationRow({
id: '00000000-0000-4000-8000-000000000072',
name: 'Stale fallback',
lat: 31,
lon: -98,
streamUrl: 'https://radio.example.com/fallback.mp3',
homepage: null,
tags: ['news'],
languages: ['English'],
state: 'Texas',
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: 'MP3',
bitrate: 128,
},
}),
];
globalThis.Audio = class FakeAudio {
constructor() {
this.volume = 0.8;
this.src = '';
}
addEventListener() {}
pause() {}
play() {
playUrls.push(this.src);
if (this.src === stationRows[0].streamUrl) {
return Promise.reject(new DOMException('Exact target unavailable', 'NotSupportedError'));
}
return Promise.resolve();
}
removeAttribute() { this.src = ''; }
load() {}
};
globalThis.fetch = async (url) => String(url).startsWith('/api/radio/click/')
? { ok: true }
: new Response(JSON.stringify({
stations: stationRows,
stale: false,
degraded: false,
acceptedGeneration: 1,
catalogInstance: 'qa-instance-a',
updatedAt: new Date().toISOString(),
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
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 {
radioLayer.init(viewer);
radioLayer.enable();
await radioLayer.update();
assert.equal(radioLayer.selectStation(stationRows[0].id, { autoplay: false }), true);
await withRadioLayer({
stations: stationRows,
failingUrls: [stationRows[0].streamUrl],
}, async ({ playUrls }) => {
// A playing stream, so the release plays.
assert.equal(radioLayer.selectStation(stationRows[1].id, { autoplay: true }), true);
await new Promise((resolve) => setTimeout(resolve));
assert.equal(radioLayer.cycleStation(1, {
stationIds: stationRows.map(({ id }) => id),
autoplay: false,
}), true, 'non-playing cycle creates the stale-fallback precondition');
fallback: true,
}), true, 'a voice-style prepared cycle creates the stale-fallback precondition');
playUrls.length = 0;
assert.equal(radioLayer.beginTuning(), true);
assert.equal(radioLayer.previewTuningStation(stationRows[0].id, { rotate: false }), true);
assert.equal(radioLayer.commitTuningStation(stationRows[0].id, { origin: 'user' }).ok, true);
@@ -975,12 +921,7 @@ test('failed exact tuner release cannot consume a stale playback fallback', asyn
assert.equal(state.audioState, 'error');
assert.equal(state.tuningStatic, true);
assert.equal(state.tuningAwaitingStationId, stationRows[0].id);
} finally {
globalThis.fetch = originalFetch;
if (originalAudio === undefined) delete globalThis.Audio;
else globalThis.Audio = originalAudio;
radioLayer.destroy();
}
});
});
test('tuner cancellation restores the frozen start marker after catalog removal', async () => {
+417
View File
@@ -0,0 +1,417 @@
// Radio selection, fallbacks and voice handoffs on the real layer and its published view.
import assert from 'node:assert/strict';
import test from 'node:test';
import { radioLayer } from './radio.js';
import { setLocale } from '../i18n/index.js';
import { renderRadioState } from '../ui/radioPresentation.js';
import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs';
const READY = 'Ready — playback starts only from your action';
const ids = [1, 2, 3].map((n) => `00000000-0000-4000-8000-00000000009${n}`);
const stations = ids.map((id, index) => radioStationRow({
id,
name: `Station ${'ABC'[index]}`,
lat: 30 + index,
lon: -97 - index,
streamUrl: `https://radio.example.com/${'abc'[index]}.mp3`,
}));
const withLayer = (failingUrls, body) => withRadioLayer({ stations, failingUrls }, body);
const settle = () => new Promise((resolve) => setTimeout(resolve));
// What the Radio panel and the Cockpit player show for a state.
function render(state) {
const previousDocument = globalThis.document;
globalThis.document = { getElementById: () => null, activeElement: null };
try {
let view = null;
const host = {
destroyed: false,
_radioPanel: {},
store: { publish: (published) => (view = published), publishTuner() {} },
actions: {
getLifecycle: () => null,
preservePanelStateDuringClear: () => false,
setPanelCollapsed() {},
scheduleLayout() {},
},
};
renderRadioState.call(host, state);
return {
status: view.playback.text,
error: Boolean(view.playback.error),
button: view.transport.play.text,
stopDisabled: view.transport.stopDisabled,
cockpit: view.cockpit.play.glyph,
};
} finally {
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
}
}
const readyToPlay = { status: READY, error: false, button: 'PLAY', stopDisabled: true, cockpit: '▶' };
test('Next before Play only selects, and Play tries just that station', async () => {
await withLayer([stations[1].streamUrl], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
assert.equal(radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }), true);
await settle();
assert.deepEqual(playUrls, []);
assert.equal(radioLayer.getUIState().audioState, 'stopped');
await radioLayer.togglePlayback({ origin: 'user' });
await settle();
const state = radioLayer.getUIState();
assert.deepEqual(playUrls, [stations[1].streamUrl], 'no fallback picked by the silent step');
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'error');
});
});
test('Next after a failed stream leaves the new station ready to play, without the old error', async () => {
setLocale('en');
await withLayer([stations[0].streamUrl], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
assert.equal(radioLayer.getUIState().audioState, 'error');
assert.equal(radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }), true);
const state = radioLayer.getUIState();
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'stopped');
assert.equal(state.playingStationId, null);
assert.deepEqual(render(state), readyToPlay);
await radioLayer.togglePlayback({ origin: 'user' });
assert.deepEqual(playUrls, [stations[0].streamUrl, stations[1].streamUrl]);
assert.equal(radioLayer.getUIState().playingStationId, ids[1]);
});
});
test('Next while paused releases the paused stream, and Play starts the new station', async () => {
setLocale('en');
await withLayer([], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
await radioLayer.togglePlayback({ origin: 'user' });
assert.equal(radioLayer.getUIState().audioState, 'paused');
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' });
const state = radioLayer.getUIState();
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'stopped');
assert.equal(state.playingStationId, null);
assert.deepEqual(render(state), readyToPlay);
await radioLayer.togglePlayback({ origin: 'user' });
assert.deepEqual(playUrls, [stations[0].streamUrl, stations[1].streamUrl]);
assert.equal(radioLayer.getUIState().audioState, 'playing');
assert.equal(radioLayer.getUIState().playingStationId, ids[1]);
});
});
test('a catalog refresh after a paused Next cannot bring back Resume for an unnamed station', async () => {
setLocale('en');
await withLayer([], async ({ catalog }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
await radioLayer.togglePlayback({ origin: 'user' });
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' });
catalog.rows = stations.filter(({ id }) => id !== ids[1]);
catalog.generation = 2;
await radioLayer.update();
const state = radioLayer.getUIState();
assert.equal(state.selected, null);
assert.equal(state.audioState, 'stopped');
const shown = render(state);
assert.equal(shown.status, READY);
assert.equal(shown.button, 'PLAY');
});
});
test('a voice station request after a voice pause keeps the fallback of that request', async () => {
const failing = [];
await withLayer(failing, async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
radioLayer.pause({ origin: 'voice-duck' });
playUrls.length = 0;
// Anchor on the far station so the paused one is not the first match.
const first = radioLayer.selectRequestedStation(
{ categoryId: 'all', anchor: { lat: 32, lon: -99 } },
{ autoplay: false },
);
failing.push(first.streamUrl);
await radioLayer.play({ origin: 'voice' });
await settle();
await settle();
assert.equal(playUrls.length, 2);
assert.equal(playUrls[0], first.streamUrl);
assert.equal(radioLayer.getUIState().audioState, 'playing');
});
});
test('a cancelled voice step leaves no fallback for a later Play from the panel', async () => {
await withLayer([stations[1].streamUrl], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
// The handoff is cancelled (barge-in), so voice never starts playback.
await settle();
await radioLayer.togglePlayback({ origin: 'user' });
await settle();
await settle();
const state = radioLayer.getUIState();
assert.deepEqual(playUrls, [stations[1].streamUrl]);
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'error');
});
});
test('only a user step to another station tells voice to drop its prepared playback', async () => {
await withLayer([], async () => {
const controls = [];
const unsubscribe = radioLayer.subscribePlaybackControls((event) => controls.push(event));
try {
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' });
radioLayer.cycleStation(1, { stationIds: [ids[1]], autoplay: false, origin: 'user' });
} finally {
unsubscribe();
}
assert.equal(radioLayer.getUIState().selected?.id, ids[1]);
assert.deepEqual(controls.map(({ action, origin }) => [action, origin]), [['select', 'user']]);
});
});
test('an unavailable tuner release stops the paused stream once, and Play still falls back', async () => {
setLocale('en');
const failing = [];
await withLayer(failing, async ({ playUrls, catalog }) => {
radioLayer.selectStation(ids[2], { autoplay: true, origin: 'user' });
await settle();
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[1], { rotate: false });
// A directory refresh changes the station under the needle mid-drag.
catalog.rows = stations.map((row) => row.id === ids[1]
? { ...row, streamUrl: 'https://radio.example.com/b2.mp3' }
: row);
catalog.generation = 2;
await radioLayer.update();
const states = [];
const controls = [];
const unsubscribe = radioLayer.subscribe((state) => states.push(state));
const unsubscribeControls = radioLayer.subscribePlaybackControls((event) => controls.push(event));
states.length = 0;
try {
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).reason, 'station-unavailable');
} finally {
unsubscribe();
unsubscribeControls();
}
assert.equal(states.length, 1);
assert.equal(states[0].tuningUnavailableStationId, ids[1]);
assert.deepEqual(controls.map(({ action, origin }) => [action, origin]), [['stop', 'user']]);
const state = radioLayer.getUIState();
assert.equal(state.selected, null);
assert.equal(state.audioState, 'stopped');
assert.equal(state.playingStationId, null);
assert.equal(render(state).button, 'PLAY');
playUrls.length = 0;
// Play picks the nearest station; its broadcaster fails.
const play = globalThis.Audio.prototype.play;
globalThis.Audio.prototype.play = function playFirstFails() {
if (!failing.length) failing.push(this.src);
return play.call(this);
};
try {
await radioLayer.togglePlayback({ origin: 'user' });
await settle();
await settle();
} finally {
globalThis.Audio.prototype.play = play;
}
assert.equal(playUrls.length, 2);
assert.equal(radioLayer.getUIState().audioState, 'playing');
});
});
test('while a stream connects the transport buttons offer Pause, because a click pauses', () => {
setLocale('en');
const shown = render({
enabled: true,
audioState: 'loading',
audioError: null,
selected: { id: 'a', name: 'Station A' },
selectedIndex: 0,
filteredCount: 1,
stationCount: 1,
volume: 1,
});
assert.equal(shown.status, 'Connecting directly to broadcaster…');
assert.equal(shown.button, 'PAUSE');
assert.equal(shown.cockpit, 'Ⅱ');
});
test('a dial release plays only if a stream was active when the drag began', async () => {
await withLayer([], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[1], { rotate: false });
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true);
await settle();
let state = radioLayer.getUIState();
assert.deepEqual(playUrls, []);
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'stopped');
assert.equal(state.tuningStatic, false);
await radioLayer.togglePlayback({ origin: 'user' });
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[2], { rotate: false });
radioLayer.commitTuningStation(ids[2], { origin: 'user' });
await settle();
state = radioLayer.getUIState();
assert.deepEqual(playUrls, [stations[1].streamUrl, stations[2].streamUrl]);
assert.equal(state.audioState, 'playing');
assert.equal(state.playingStationId, ids[2]);
});
});
// Like Chrome, resume() flips `state` only once the device starts, and suspend()
// on a context that is still suspended resolves at once without effect.
const RESUME_MS = 5;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function withFakeAudioContext(body) {
const log = { contexts: [], sources: [] };
const node = () => ({ connect() {}, disconnect() {} });
class FakeAudioContext {
constructor() {
Object.assign(this, { state: 'suspended', sampleRate: 8000, currentTime: 0, destination: {} });
log.contexts.push(this);
}
resume() {
if (this.state === 'running') return Promise.resolve();
return new Promise((resolve) => setTimeout(() => {
if (this.state !== 'closed') this.state = 'running';
resolve();
}, RESUME_MS));
}
suspend() {
if (this.state === 'running') this.state = 'suspended';
return Promise.resolve();
}
close() { this.state = 'closed'; return Promise.resolve(); }
createBuffer(_channels, length) { return { getChannelData: () => new Float32Array(length) }; }
createBufferSource() {
const source = { ...node(), started: false, stopped: false, start() { this.started = true; }, stop() { this.stopped = true; } };
log.sources.push(source);
return source;
}
createBiquadFilter() { return { ...node(), frequency: { value: 0 }, Q: { value: 0 } }; }
createGain() {
return { ...node(), gain: { value: 0, cancelScheduledValues() {}, setValueAtTime() {}, linearRampToValueAtTime() {} } };
}
}
const original = globalThis.AudioContext;
globalThis.AudioContext = FakeAudioContext;
return Promise.resolve(body(log)).finally(() => {
if (original === undefined) delete globalThis.AudioContext;
else globalThis.AudioContext = original;
});
}
test('a silent dial release stops the tuner noise and suspends its audio context, even before resume() settles', async () => {
await withFakeAudioContext(async ({ contexts, sources }) => {
await withLayer([], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[1], { rotate: false });
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true);
await sleep(RESUME_MS * 4);
assert.deepEqual(playUrls, []);
assert.equal(sources[0].started, true);
assert.equal(sources[0].stopped, true, 'noise source still looping');
assert.equal(contexts[0].state, 'suspended', 'audio context still running');
});
});
});
test('a second gesture before the first resume() settles keeps its noise graph', async () => {
await withFakeAudioContext(async ({ contexts, sources }) => {
await withLayer([], async () => {
radioLayer.selectStation(ids[0], { autoplay: false });
radioLayer.beginTuning();
radioLayer.commitTuningStation(ids[0], { origin: 'user' });
assert.equal(radioLayer.beginTuning(), true);
await sleep(RESUME_MS * 4);
assert.equal(contexts[0].state, 'running', 'second gesture lost its context');
assert.equal(sources.at(-1).stopped, false, 'second gesture lost its source');
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
await sleep(RESUME_MS * 4);
assert.equal(sources.at(-1).stopped, true);
assert.equal(contexts[0].state, 'suspended');
});
});
});
test('a dial release from a paused stream only selects, without static', async () => {
await withFakeAudioContext(async ({ contexts }) => {
await withLayer([], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
await radioLayer.togglePlayback({ origin: 'user' });
assert.equal(radioLayer.getUIState().audioState, 'paused');
assert.equal(radioLayer.beginTuning(), true);
await sleep(RESUME_MS * 4);
radioLayer.previewTuningStation(ids[1], { rotate: false });
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
await sleep(RESUME_MS * 4);
const state = radioLayer.getUIState();
assert.deepEqual(playUrls, [stations[0].streamUrl]);
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'stopped');
assert.equal(state.tuningStatic, false);
assert.equal(state.tuningAwaitingStationId, null);
assert.equal(contexts[0].state, 'suspended');
});
});
});
test('a dial release while a stream is still connecting plays the new station', async () => {
await withLayer([], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
assert.equal(radioLayer.getUIState().audioState, 'loading');
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[1], { rotate: false });
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
await settle();
const state = radioLayer.getUIState();
assert.equal(playUrls.at(-1), stations[1].streamUrl);
assert.equal(state.audioState, 'playing');
assert.equal(state.playingStationId, ids[1]);
});
});
test('a silent user step drops the fallback a voice step left behind', async () => {
await withLayer(stations.map(({ streamUrl }) => streamUrl), async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
// Voice steps to B and keeps C as its fallback; the user steps back to A.
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
radioLayer.cycleStation(-1, { stationIds: ids, autoplay: false, origin: 'user' });
await radioLayer.play({ origin: 'voice' });
await settle();
await settle();
assert.deepEqual(playUrls, [stations[0].streamUrl]);
assert.equal(radioLayer.getUIState().audioState, 'error');
});
});
test('a silent dial release drops the fallback a voice step left behind', async () => {
await withLayer(stations.map(({ streamUrl }) => streamUrl), async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[0], { rotate: false });
radioLayer.commitTuningStation(ids[0], { origin: 'user' });
await radioLayer.play({ origin: 'voice' });
await settle();
await settle();
assert.deepEqual(playUrls, [stations[0].streamUrl]);
assert.equal(radioLayer.getUIState().audioState, 'error');
});
});
+45
View File
@@ -19,6 +19,7 @@ import assert from 'node:assert/strict';
import {
resolveEllipsoidalGround,
cachedEllipsoidalGround,
cachedRealEllipsoidalGround,
} from './terrainHeights.js';
const AUSTIN = { lat: 30.2672, lon: -97.7431 };
@@ -362,3 +363,47 @@ test('resolveEllipsoidalGround: null and string heights cannot become real zeroe
);
}
});
test('resolveEllipsoidalGround: a 502 keeps the real heights it carries and falls back only for the rest', async () => {
const coords = [
{ lat: 62.001, lon: -150.001, sourceOrthometricM: 10 },
{ lat: 62.002, lon: -150.002, sourceOrthometricM: 10 },
{ lat: 62.003, lon: -150.003, sourceOrthometricM: 10 },
];
await withFakeFetch(
async () => ({
ok: false,
status: 502,
json: async () => ({
error: 'terrain heights fetch failed and no cache available for every point',
partial: [{ ellipsoid: 412.5 }, null, { ellipsoid: 'x' }],
}),
}),
async () => {
const out = await resolveEllipsoidalGround(coords);
assert.deepEqual(out[0], { ellipsoid: 412.5, source: 'reearth' });
assert.equal(out[1].source, 'geoid-fallback');
assert.equal(out[2].source, 'geoid-fallback', 'a non-numeric height is not real');
}
);
assert.equal(cachedRealEllipsoidalGround(62.001, -150.001), 412.5);
assert.equal(cachedRealEllipsoidalGround(62.002, -150.002), null);
});
test('resolveEllipsoidalGround: a partial list of the wrong length is ignored', async () => {
const coords = [
{ lat: 63.001, lon: -151.001 },
{ lat: 63.002, lon: -151.002 },
];
await withFakeFetch(
async () => ({
ok: false,
status: 502,
json: async () => ({ error: 'x', partial: [{ ellipsoid: 7 }] }),
}),
async () => {
const out = await resolveEllipsoidalGround(coords);
assert.deepEqual(out.map((r) => r.source), ['geoid-fallback', 'geoid-fallback']);
}
);
});
+10 -1
View File
@@ -190,7 +190,8 @@ export async function fetchTerrainChunkWithRetry(
* Resolve a requested point batch against a per-point cache and a missing-only
* fetcher. Results are reconstructed by canonical key in exact request order,
* including duplicates. Stale values remain eligible only when refresh fails.
* If any position has no real height, return 502 with no fabricated result.
* If any position has no real height, return 502 with no fabricated result;
* the body's `partial` lists the real heights it has, null for the rest.
*
* @param {object} options
* @param {Array<[number, number]>} options.points
@@ -269,11 +270,19 @@ export async function resolveTerrainHeightRequest({
return entry && validTerrainResult(entry.result) ? entry.result : null;
});
if (results.some((result) => result == null)) {
// The real heights this request does have, null elsewhere, so the page
// keeps them and uses its geoid only for the rest. Named `partial`, not
// `results`, so no reader mistakes it for a complete answer; left out when
// no point has a height.
const partial = results.some((result) => result != null)
? { partial: results }
: {};
return {
status: 502,
body: {
error:
'terrain heights fetch failed and no cache available for every point',
...partial,
},
cacheChanged,
upstreamError,
+28
View File
@@ -268,3 +268,31 @@ test('terrain parser rejects out-of-range WGS84 points and accepts inclusive bou
[0, 0],
]);
});
test('a 502 lists the real heights it has, null for the rest', async () => {
const cache = new Map([
[terrainPointKey([1, 1]), { at: 0, result: result('stale-a', 11) }],
]);
const response = await resolveTerrainHeightRequest({
points: [
[1, 1],
[2, 2],
[1, 1],
],
cache,
ttlMs: 100,
now: () => 1000,
fetchMissing: async () => {
const error = new Error('HTTP 429');
error.status = 429;
error.coolingDown = true;
throw error;
},
});
assert.equal(response.status, 502);
assert.equal('results' in response.body, false);
assert.deepEqual(
response.body.partial.map((item) => item?.id ?? null),
['stale-a', null, 'stale-a'],
);
});
+4 -3
View File
@@ -1030,11 +1030,12 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// preserves the mission mapping and globe-rotation arguments.
// S2 GhostMaps extends the generated layer enums; both feature contracts remain.
// The layer alias hint leaves out aliases the layer id already spells.
// The Utilities panel adds one set_panel_open target.
assert.equal(block.length, 30919, 'serialized tool schema length drifted');
// The Utilities panel adds one set_panel_open target; Sea Marks and Ski
// Pistes extend the generated layer enums and alias hint.
assert.equal(block.length, 31026, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'dbf9a45649179e626b2e3d938c43903f573c50ffa279b3245b18244dbe9a9284',
'5f5275cd6e320afef4fd224b235f3e0f191ec06599b257ffe1f60b703a255199',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(
+152 -40
View File
@@ -32,17 +32,31 @@ import {
} from './data/geoid.js';
import { getBasemapLabelContext } from './voice/gevActions.js';
import {
hudSummaryMatchesProvenance,
hasWord,
hudSummaryHasEnglish,
hudSummaryIsLatin,
hudSummaryLayerContext,
hudTelemetryProvenance,
isHudSummaryUnconfigured,
} from './hudSummaryResponse.js';
import {
hudSummaryMatchesProvenance,
provenanceLine,
provenanceTag,
} from './data/layerSnapshot.js';
import {
applyHudUiTheme,
DEFAULT_HUD_LAYOUT,
normalizeHudLayout,
} from './hudLayouts.js';
import { formatNumber, msg, onLocaleChange, t } from './i18n/index.js';
import {
formatNumber,
getLocale,
msg,
onLocaleChange,
t,
} from './i18n/index.js';
import { isRtlLocale } from './i18n/locale.js';
import { localizeLayerName } from './ui/layerPanel.js';
import { isEmbeddedInline } from './app/embed.js';
import { subscribeSelectedVessel } from './hudReadouts.js';
@@ -108,23 +122,58 @@ const FEED_STATE_LABELS = Object.freeze({
unavailable: msg('UNAVAILABLE'),
});
/** A summary line with its warning in the active language. */
function withProvenance(text, provenance) {
return provenanceLine(
text,
provenance,
(state) =>
FEED_STATE_LABELS[state]
? t(FEED_STATE_LABELS[state])
: state.toUpperCase(),
(name) => localizeLayerName(name).toUpperCase(),
);
}
/**
* The summary's feed-state suffix in the active language, e.g.
* `UNAVAILABLE: LIVE AIS VESSELS`. State ids and layer names stay English in
* layer data; only this line translates them.
* The AI line. In an RTL locale a leading right-to-left mark keeps a reply
* that opens with a Latin name (Times Square …) reading right to left.
*/
function localizedProvenanceTag(provenance) {
if (!provenance) return null;
const state = FEED_STATE_LABELS[provenance.state]
? t(FEED_STATE_LABELS[provenance.state])
: provenance.state.toUpperCase();
if (!provenance.names.length) return state;
return t('{state} {layers}', {
state,
layers: provenance.names
.map((name) => localizeLayerName(name).toUpperCase())
.join('/'),
});
function aiSummaryLine(summary, warning) {
const line = withProvenance(summary, warning);
return isRtlLocale(getLocale()) ? `\u200f${line}` : line;
}
/**
* What a summary outside English must not carry: feed-state ids, translated
* layers' English names and ids, and with every feed healthy the
* UNAVAILABLE word. A state word the locale keeps in English (de FALLBACK)
* and words of a place label (Radio City, Loading Dock) are fine; so is a
* one-word layer name in a Latin-script locale, where it may be a word of
* the language (fr transit).
*/
function summaryForbiddenWords(context) {
const places = [
...(context.placeLabels || []),
...(context.streetLabels || []),
...(context.nearbyPlaceLabels || []),
].join(' ');
const latin = hudSummaryIsLatin(context.locale);
const overall = context.feedProvenance?.overall;
return [
...Object.keys(FEED_STATE_LABELS).filter(
(state) =>
t(FEED_STATE_LABELS[state]).toLowerCase() !== state &&
!hasWord(places, state),
),
...(context.englishLayerWords || []).filter(
(word) =>
!hasWord(places, word) && (!latin || /[^\p{L}\p{N}]/u.test(word)),
),
...(!overall || overall === 'nominal'
? [t(FEED_STATE_LABELS.unavailable)]
: []),
];
}
/** The name shown for a _viewBand() id. */
@@ -221,7 +270,7 @@ export class IntelHUD {
this._placeLabels = null;
this._lastSummarySignature = '';
/** The reply painted for `_lastSummarySignature`, when it was accepted. */
this._lastSummaryText = null;
this._lastSummaryReply = null;
this._summaryRevision = 0;
// One-shot guards so the very first summary lands immediately instead of
// waiting for the 15s interval tick: B) swap the "Awaiting telemetry..."
@@ -250,12 +299,17 @@ export class IntelHUD {
// the camera has moved to.
this._onCameraMoveStart = () => {
this._markSummaryDirty();
if (this._summaryRequest || this._summaryTypingInterval) {
const typing = Boolean(this._summaryTypingInterval);
if (this._summaryRequest || typing) {
// Cancelled requests and partially typed replies are not reusable answers.
this._lastSummarySignature = null;
}
this._summaryRequest?.abort();
this._stopSummaryTyping();
// A half-typed reply would stay until moveEnd, which a camera that
// keeps moving may not send for a long time: show the plain line.
if (typing && this._latestMetrics)
this._setSummaryText(this._composeSummary(), false);
};
this._onCameraMoveEnd = () => {
this._markSummaryDirty();
@@ -295,12 +349,12 @@ export class IntelHUD {
this._startTimers();
}
/**
* Show the HUD's own words in the new locale. The deterministic summary
* is composed again; the AI reply does not depend on the locale, so the
* next tick shows the one already received rather than asking again.
*/
/** Show the HUD's words in the new locale; drop AI text in the old one. */
_relocalize() {
this._markSummaryDirty();
this._lastSummarySignature = null;
this._lastSummaryReply = null;
this._summaryRequest?.abort();
this._present({
mode: MODE_LABELS[this._currentStyle]
? t(MODE_LABELS[this._currentStyle])
@@ -763,8 +817,8 @@ export class IntelHUD {
LOCALIZED_LOCALITY,
);
const provenance = localizedProvenanceTag(
hudTelemetryProvenance(this._dataManager?.getAll?.() || []),
const provenance = hudTelemetryProvenance(
this._dataManager?.getAll?.() || [],
);
// ALT, SUN and ONA are sensor codes, as in the corner readouts.
const line = t(
@@ -781,7 +835,7 @@ export class IntelHUD {
zone: localTag,
},
);
return provenance ? `${line} | ${provenance}` : line;
return withProvenance(line, provenance);
}
/** Stop a summary still typing out, leaving what it has typed so far. */
@@ -854,12 +908,28 @@ export class IntelHUD {
return;
}
if (revision !== this._summaryRevision) return;
// Without a place label the model invents a place: show the plain line.
const described = [
context.placeLabels,
context.streetLabels,
context.nearbyPlaceLabels,
].some((list) => list?.length);
if (!described) {
this._summaryDirty = false;
this._setSummaryText(fallbackText, false);
return;
}
const signature = JSON.stringify(context);
if (!force && signature === this._lastSummarySignature) {
this._summaryDirty = false;
// A settle painted the fallback line over the reply this context
// already has; reused place labels make that common, so restore it.
if (this._lastSummaryText) this._setSummaryText(this._lastSummaryText);
// The warning is the current one: layer states are not in a
// non-English signature.
if (this._lastSummaryReply)
this._setSummaryText(
aiSummaryLine(this._lastSummaryReply, context.warning),
);
return;
}
if (this._summaryRequest) return;
@@ -867,7 +937,7 @@ export class IntelHUD {
if (force) this._setSummaryText(fallbackText, false);
this._summaryDirty = false;
this._lastSummarySignature = signature;
this._lastSummaryText = null;
this._lastSummaryReply = null;
const controller = new AbortController();
let timedOut = false;
const timeout = window.setTimeout(() => {
@@ -890,9 +960,23 @@ export class IntelHUD {
if (!response.ok || !data?.summary) {
throw new Error(data?.error || `HTTP ${response.status}`);
}
if (hudSummaryMatchesProvenance(data.summary, context.feedProvenance))
this._lastSummaryText = data.summary;
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
if (
hudSummaryMatchesProvenance(data, context.feedProvenance) &&
!hudSummaryHasEnglish(
data.summary,
context.locale,
summaryForbiddenWords(context),
)
) {
this._lastSummaryReply = data.summary;
}
// The warning is the HUD's own translated state, not the model's words.
this._setSummaryText(
this._lastSummaryReply
? aiSummaryLine(this._lastSummaryReply, context.warning)
: fallbackText,
animate,
);
} catch (error) {
// The 5 s timeout is a failure like any other; a move's abort (or
// destroy's) is not.
@@ -950,18 +1034,46 @@ export class IntelHUD {
// A slower build for a view the camera has left must not replace the
// newer view's lookup.
if (revision === this._summaryRevision) this._placeLabels = labels;
const enabledLayers =
this._dataManager
?.getAll?.()
?.filter((layer) => layer.enabled)
.map((layer) => layer.name) || [];
return {
const locale = getLocale();
const layerContext = hudSummaryLayerContext(
this._dataManager?.getAll?.() || [],
);
// Outside English a layer's state id is left out: the model echoes it,
// and `overall` already carries the state.
const named = ({ feedState, ...layer }) => ({
...layer,
name: localizeLayerName(layer.name),
...(locale === 'en' && { feedState }),
});
const { feedProvenance } = layerContext;
const context = {
locale,
placeLabels: labels.placeLabels,
streetLabels: labels.streetLabels,
nearbyPlaceLabels: labels.nearbyPlaceLabels,
enabledLayerLabels: enabledLayers,
...hudSummaryLayerContext(this._dataManager?.getAll?.() || []),
// The model sees only the layer names the user sees. The English note
// goes too: a small model copies its words. `overall` stays the id.
enabledLayerLabels:
layerContext.enabledLayerLabels.map(localizeLayerName),
enabledLayers: layerContext.enabledLayers.map(named),
feedProvenance: {
...feedProvenance,
layers: feedProvenance.layers.map(named),
note: locale === 'en' ? feedProvenance.note : undefined,
},
};
// Not sent and not part of the signature: the line's own warning, taken
// before the layer states are left out, and the English names and ids of
// translated layers, which a reply in another language must not echo.
Object.defineProperties(context, {
warning: { value: provenanceTag(feedProvenance) },
englishLayerWords: {
value: layerContext.enabledLayers
.filter(({ name }) => localizeLayerName(name) !== name)
.flatMap(({ name, id }) => [name, id].filter(Boolean)),
},
});
return context;
}
_markSummaryDirty() {
+11 -9
View File
@@ -295,7 +295,7 @@ test('a summary asked for one view is dropped once the camera moves', async () =
try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
let place = 'Yellowstone';
hud._summaryContext = async () => ({ place });
hud._summaryContext = async () => ({ placeLabels: [place] });
const summary = () => env.elements.get('hud-summary').textContent;
const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData();
@@ -321,7 +321,7 @@ test('a summary asked for one view is dropped once the camera moves', async () =
const moving = hud._updateSummary(false);
await settle();
assert.equal(pending.length, 2, 'the move marked the summary dirty');
assert.deepEqual(pending[1].context, { place: 'Atlantic' });
assert.deepEqual(pending[1].context, { placeLabels: ['Atlantic'] });
pending[1].reply('North Atlantic Ocean');
await moving;
assert.equal(summary(), 'North Atlantic Ocean');
@@ -354,7 +354,7 @@ test('a request the camera leaves paints nothing: aborted, failed, or its contex
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
hud._summaryContext = async () => ({ place: 'Yellowstone' });
hud._summaryContext = async () => ({ placeLabels: ['Yellowstone'] });
const element = env.elements.get('hud-summary');
const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData();
@@ -416,7 +416,7 @@ test('a cancelled summary retries the same context but completed answers stay re
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
hud._summaryContext = async () => ({ place: 'San Francisco' });
hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] });
hud._updateCameraData();
const settle = () => new Promise((resolve) => setImmediate(resolve));
const first = hud._updateSummary(false);
@@ -474,7 +474,7 @@ test('after a dropped or timed-out request the next tick asks again, and a move
try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
// The same place before and after: a slow follow camera, a small pan.
hud._summaryContext = async () => ({ place: 'Lisboa' });
hud._summaryContext = async () => ({ placeLabels: ['Lisboa'] });
const element = env.elements.get('hud-summary');
const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData();
@@ -501,15 +501,17 @@ test('after a dropped or timed-out request the next tick asks again, and a move
});
await update;
// A reply still typing stops when the camera starts moving.
// A reply still typing stops when the camera starts moving, and the
// plain line replaces the fragment.
hud._typeSummary('Canyon Junction Wyoming Yellowstone National Park');
t.mock.timers.tick(48);
const typed = element.textContent;
env.cameraListeners.moveStart();
const plain = element.textContent;
assert.equal(plain, hud._composeSummary());
t.mock.timers.tick(2_000);
assert.equal(
element.textContent,
typed,
plain,
'no more characters after the move',
);
} finally {
@@ -536,7 +538,7 @@ test('a summary interrupted while typing can retry unchanged context', async (t)
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
hud._summaryContext = async () => ({ place: 'San Francisco' });
hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] });
hud._updateCameraData();
await hud._updateSummary(true);
t.mock.timers.tick(48);
+300
View File
@@ -0,0 +1,300 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import './testSupport/stubMgrs.mjs';
import { hudReadouts } from './testSupport/hudReadouts.mjs';
const { IntelHUD } = await import('./hud.js');
const { setLocale } = await import('./i18n/index.js');
const { hudSummaryHasEnglish, hudSummaryInput } =
await import('./hudSummaryResponse.js');
const PARTIAL_FLIGHTS = {
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: { count: 4, partial: true, source: 'opensky' },
};
function createHud(
t,
reply,
{ places = ['Остин'], locale = 'ru', layers = () => [PARTIAL_FLIGHTS] } = {},
) {
const { elements, publish } = hudReadouts();
const previous = { document: globalThis.document, window: globalThis.window };
globalThis.document = {
hidden: false,
visibilityState: 'visible',
getElementById: (id) => elements.get(id) ?? null,
querySelectorAll: () => [],
addEventListener() {},
removeEventListener() {},
};
globalThis.window ??= {
setTimeout,
clearTimeout,
setInterval,
clearInterval,
};
t.mock.timers.enable({ apis: ['setInterval'] });
const viewer = {
scene: { canvas: { width: 0, height: 0 }, pickPositionSupported: false },
camera: {
pitch: -Math.PI / 2,
heading: 0,
positionCartographic: { latitude: 0.53, longitude: -1.7, height: 600 },
computeViewRectangle: () => undefined,
moveStart: { addEventListener() {}, removeEventListener() {} },
moveEnd: { addEventListener() {}, removeEventListener() {} },
},
};
const sent = [];
const hud = new IntelHUD(viewer, {
publish,
summaryService: {
configured: async () => true,
async summarize(context) {
sent.push(context);
return { ok: true, status: 200, data: { error: null, ...reply } };
},
},
});
t.after(() => {
hud.destroy();
setLocale('en');
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete globalThis[key];
else globalThis[key] = value;
}
});
// The test viewer has no view target, so no place lookup runs.
const buildContext = hud._summaryContext.bind(hud);
hud._summaryContext = async () =>
Object.assign(await buildContext(), { placeLabels: places });
hud._dataManager = { getAll: layers };
hud._latestMetrics = { altM: 600 };
setLocale(locale);
return {
hud,
sent,
shown: () => elements.get('hud-summary').textContent,
};
}
test('a ru HUD request carries no English layer name', async (t) => {
const { hud } = createHud(t, {});
const context = await hud._summaryContext();
assert.equal(context.locale, 'ru');
assert.deepEqual(context.enabledLayerLabels, ['Рейсы онлайн']);
const input = hudSummaryInput(context);
assert.doesNotMatch(input, /Live Flights/, input);
// The locale signs the dedupe signature; the English names stay local.
assert.match(JSON.stringify(context), /"locale":"ru"/);
assert.doesNotMatch(JSON.stringify(context), /englishLayerWords|warning/);
});
test('a ru summary shows the state word first and the layer names last', async (t) => {
const { hud, shown } = createHud(t, {
summary: 'Аэропорт задерживает рейсы',
feedState: 'partial',
});
await hud._updateSummary(false, true);
assert.equal(shown(), 'ЧАСТИЧНО | Аэропорт задерживает рейсы | РЕЙСЫ ОНЛАЙН');
});
for (const words of [
'Live Flights частично онлайн',
'Рейсы онлайн stale данные',
'Остин flights частично онлайн',
'Austin flights partially online',
]) {
test(`a ru reply echoing English falls back: ${words}`, async (t) => {
const { hud, shown } = createHud(t, {
summary: words,
feedState: 'partial',
});
await hud._updateSummary(false, true);
assert.ok(shown().startsWith('ЧАСТИЧНО | '), shown());
assert.ok(!shown().includes(words), shown());
});
}
test('a view with layers but no place label does not ask the model', async (t) => {
const { hud, sent, shown } = createHud(
t,
{ summary: 'Москва Рейсы онлайн частично', feedState: 'partial' },
{ places: [] },
);
await hud._updateSummary(false, true);
assert.equal(sent.length, 0);
assert.equal(shown(), hud._composeSummary());
});
test('a ja request leaves the layer state ids to `overall`', async (t) => {
const { hud } = createHud(t, {}, { locale: 'ja' });
const context = await hud._summaryContext();
assert.equal(context.feedProvenance.overall, 'partial');
assert.ok(!('feedState' in context.enabledLayers[0]));
assert.ok(!('feedState' in context.feedProvenance.layers[0]));
});
for (const [locale, summary] of [
['ja', '東京上空のstale航空情報'],
['ja', '東京上空のLive Flights'],
['zh-CN', '北京航班stale数据'],
['ru', 'Остин Live-Flights частично'],
]) {
test(`English flush against other text is an echo: ${summary}`, () => {
assert.ok(hudSummaryHasEnglish(summary, locale, ['stale', 'Live Flights']));
});
}
test('English inside a longer Latin word is not an echo', () => {
assert.ok(!hudSummaryHasEnglish('Datos stalesque', 'es', ['stale']));
});
for (const [locale, summary, places] of [
['es', 'Tráfico aéreo nominal sobre Madrid', ['Madrid']],
['de', 'Fallback Daten über Berlin', ['Berlin']],
['ja', 'Live Flights Plaza周辺', ['Live Flights Plaza']],
['es', 'Estación Loading Dock Madrid', ['Estación Loading Dock']],
]) {
test(`a ${locale} word that is not an echo is shown: ${summary}`, async (t) => {
const { hud, shown } = createHud(
t,
{ summary, feedState: 'partial' },
{ places, locale },
);
await hud._updateSummary(false, true);
assert.ok(shown().includes(summary), shown());
});
}
test('a language switch drops the reply in flight and asks again', async (t) => {
const { hud, sent, shown } = createHud(t, {});
let answer;
hud.summaryService.summarize = (context, { signal }) => {
sent.push(context);
return new Promise((resolve, reject) => {
answer = () =>
resolve({
ok: true,
status: 200,
data: {
error: null,
summary: 'Остин рейсы частично',
feedState: 'partial',
},
});
signal.addEventListener('abort', () =>
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
);
});
};
const pending = hud._updateSummary(false, true);
while (!answer) await new Promise((resolve) => setImmediate(resolve));
setLocale('es');
answer();
await pending;
assert.ok(!shown().includes('Остин'), shown());
assert.equal(shown(), hud._composeSummary());
hud.summaryService.summarize = async (context) => {
sent.push(context);
return {
ok: true,
status: 200,
data: {
error: null,
summary: 'Austin vuelos parciales',
feedState: 'partial',
},
};
};
await hud._updateSummary(false);
assert.equal(sent.at(-1).locale, 'es');
assert.ok(shown().startsWith('PARCIAL | Austin vuelos parciales'), shown());
});
test('a reused ru reply carries the current warning, not the cached one', async (t) => {
// Both layers load an empty view: no count, no lastUpdate yet.
let flights = { loading: true, count: 0 };
const { hud, shown } = createHud(
t,
{ summary: 'Остин небо над городом', feedState: 'loading' },
{
layers: () => [
{ id: 'flights', name: 'Live Flights', enabled: true, stats: flights },
{
id: 'ais',
name: 'Live AIS Vessels',
enabled: true,
stats: { loading: true, count: 0 },
},
],
},
);
await hud._updateSummary(false, true);
assert.match(shown(), /РЕЙСЫ ОНЛАЙН/, shown());
// Live Flights finishes with nothing to show; AIS is still loading.
flights = { count: 0 };
hud._markSummaryDirty();
await hud._updateSummary(false);
assert.match(
shown(),
/^ЗАГРУЗКА \| Остин небо над городом \| СУДА AIS/,
shown(),
);
assert.doesNotMatch(shown(), /РЕЙСЫ ОНЛАЙН/, shown());
});
test('with every feed healthy a reply calling one unavailable falls back', async (t) => {
const { hud, shown } = createHud(
t,
{ summary: 'Остин рейсы недоступно сейчас', feedState: null },
{ layers: () => [] },
);
await hud._updateSummary(false, true);
assert.equal(shown(), hud._composeSummary());
});
for (const [locale, place, summary] of [
['zh-CN', '北京', '北京航班不可用'],
['zh-Hant', '北京', '北京航班不可用'],
['ja', '東京', '東京航空便利用不可'],
]) {
test(`a ${locale} reply calling a feed unavailable flush against Han falls back`, async (t) => {
const { hud, shown } = createHud(
t,
{ summary, feedState: null },
{ locale, places: [place], layers: () => [] },
);
await hud._updateSummary(false, true);
assert.equal(shown(), hud._composeSummary());
});
}
test('an ar reply opening with a Latin name keeps reading right to left', async (t) => {
const { hud, shown } = createHud(
t,
{ summary: 'Times Square نيويورك الولايات المتحدة', feedState: null },
{ locale: 'ar', places: ['Times Square'], layers: () => [] },
);
await hud._updateSummary(false, true);
assert.equal(shown(), '\u200fTimes Square نيويورك الولايات المتحدة');
});
test('a one-word layer name that is a French word is not an echo', async (t) => {
const summary = 'Bus en transit à Paris';
const { hud, shown } = createHud(
t,
{ summary, feedState: null },
{
locale: 'fr',
places: ['Paris'],
layers: () => [{ id: 'transit', name: 'Transit', enabled: true }],
},
);
await hud._updateSummary(false, true);
assert.equal(shown(), summary);
});
+181 -27
View File
@@ -1,7 +1,10 @@
import {
SNAPSHOT_FEED_STATES,
feedProvenanceEnvelope,
layerSnapshots,
provenanceTag,
} from './data/layerSnapshot.js';
import { DEFAULT_LOCALE, normalizeLocale } from './i18n/locale.js';
export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED';
@@ -43,19 +46,189 @@ export function isHudSummaryUnconfigured(status, data) {
}
/**
* HUD summary model instructions. The five-word line may only describe
* supplied labels, and must carry feed-state when any enabled layer is not
* nominal.
* HUD summary model instructions. The five words may only describe supplied
* labels; the feed state comes back as its own canonical field, so the check
* does not depend on the summary's language or wording.
*/
export const HUD_SUMMARY_INSTRUCTIONS = [
"Write one concise intelligence-HUD summary for God's Eye View.",
'Use only the supplied place, street, nearby-place, enabled-layer labels, and feedProvenance.',
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
'If any enabled layer is not nominal, the five words MUST include that feedState token (STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE) and must not present the view as live.',
'In the summary, name layers only as in enabledLayerLabels; never copy layer names or state words from feedProvenance into it.',
'Set feedState to feedProvenance.overall exactly as given, or null when it is null.',
'When feedProvenance.overall is null, say nothing about feeds or layers, present or missing.',
'If feedProvenance.overall is partial, do not present the coverage as complete; if it is any other state but nominal, do not present the view as live or healthy.',
'Do not infer from coordinates or invent a place or feed state.',
'Output exactly five words with no title, punctuation, markdown, or introductory phrase.',
// "Exactly five words" made a small model pad a short view with words like
// "no data" or "unavailable".
'The summary has at most five words, with no title, punctuation, markdown, or introductory phrase.',
].join(' ');
/** Longest summary, in characters, for a script written without spaces. */
export const HUD_SUMMARY_MAX_UNSPACED_CHARS = 20;
/** Longest summary, in characters; the cut falls between words. */
export const HUD_SUMMARY_MAX_CHARS = 80;
/** Responses API structured output: the summary plus the canonical state. */
export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({
type: 'json_schema',
name: 'hud_summary',
strict: true,
schema: {
type: 'object',
properties: {
summary: { type: 'string' },
// Nullable rather than a `none` token, which the model wrote into the
// summary of a view without layers.
feedState: {
anyOf: [
{ type: 'string', enum: [...SNAPSHOT_FEED_STATES] },
{ type: 'null' },
],
},
},
required: ['summary', 'feedState'],
additionalProperties: false,
},
});
/** A shipped locale for the request, English when missing or unsupported. */
export function hudSummaryLocale(value) {
return normalizeLocale(value) || DEFAULT_LOCALE;
}
// Names Intl.DisplayNames gets wrong for the shipped catalogs: pt is the
// Brazilian variant, and zh-Hant is the zh-CN wording in Traditional script.
const SUMMARY_LANGUAGES = Object.freeze({
pt: 'Brazilian Portuguese',
'zh-CN': 'Simplified Chinese',
'zh-Hant': 'Chinese in Traditional characters (mainland wording)',
});
/** Scripts written without spaces, capped in characters instead of words. */
const UNSPACED_LOCALES = /^(?:ja|zh)(?:-|$)/;
/** Shipped locales not written in Latin letters. */
const NON_LATIN_LOCALES = /^(?:ar|ja|ru|uk|zh)(?:-|$)/;
/** Scripts whose letters extend a word; any other neighbour ends it. */
const WORD_SCRIPTS = [
'Latin',
'Cyrillic',
'Greek',
'Arabic',
'Hebrew',
'Han',
'Hiragana',
'Katakana',
'Hangul',
].map((script) => `\\p{Script=${script}}`);
/** Lower case, with punctuation and brackets as spaces: `Earthquakes 24h`. */
function flatWords(value) {
return String(value || '')
.normalize('NFC')
.toLowerCase()
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
.trim();
}
const UNSPACED_WORD =
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ー]+$/u;
function wordScript(char) {
return WORD_SCRIPTS.find((script) => new RegExp(script, 'u').test(char));
}
/**
* Whether `word` stands on its own in `text`. Only a letter of the same
* script extends it, so `stale` flush against kana or Han still counts.
* Han and kana have no word edges: a word in them counts anywhere.
* @param {string} text
* @param {string} word
* @returns {boolean}
*/
export function hasWord(text, word) {
const haystack = flatWords(text);
const needle = flatWords(word);
if (!needle) return false;
if (UNSPACED_WORD.test(needle)) return haystack.includes(needle);
const chars = [...needle];
const before = wordScript(chars[0]);
const after = wordScript(chars.at(-1));
// flatWords leaves no regex syntax in the needle.
return new RegExp(
`${before ? `(?<!${before})` : ''}${needle}${after ? `(?!${after})` : ''}`,
'u',
).test(haystack);
}
/**
* Whether a non-English summary carries what the HUD does not show: one of
* `words` (feed-state ids, translated layers' English names and ids), or only
* Latin letters in a language written in another script.
* @param {string} summary The model's words.
* @param {unknown} locale The request locale.
* @param {string[]} [words] Words the summary must not carry.
* @returns {boolean}
*/
export function hudSummaryHasEnglish(summary, locale, words = []) {
const tag = hudSummaryLocale(locale);
if (tag === DEFAULT_LOCALE) return false;
const letters = String(summary || '').replace(/[^\p{L}]/gu, '');
if (!hudSummaryIsLatin(tag) && !letters.replace(/\p{Script=Latin}/gu, ''))
return true;
return words.some((word) => hasWord(summary, word));
}
/** Whether the locale is written in Latin letters. */
export function hudSummaryIsLatin(locale) {
return !NON_LATIN_LOCALES.test(hudSummaryLocale(locale));
}
export function hudSummaryIsUnspaced(locale) {
return UNSPACED_LOCALES.test(hudSummaryLocale(locale));
}
/** The language a request's summary is written in, named in English. */
function summaryLanguage(tag) {
if (SUMMARY_LANGUAGES[tag]) return SUMMARY_LANGUAGES[tag];
try {
return new Intl.DisplayNames(['en'], { type: 'language' }).of(tag) || tag;
} catch {
return tag;
}
}
/**
* @param {unknown} locale The `locale` the HUD sent.
* @returns {string}
*/
export function hudSummaryInstructions(locale) {
const tag = hudSummaryLocale(locale);
if (tag === DEFAULT_LOCALE) return HUD_SUMMARY_INSTRUCTIONS;
const language = summaryLanguage(tag);
const length = hudSummaryIsUnspaced(tag)
? ` Instead of five words, write one phrase of at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters.`
: '';
return `${HUD_SUMMARY_INSTRUCTIONS} Write the summary in ${language} (${tag}), not English. Give places their usual names in that language and layers the names in enabledLayerLabels; feedState stays the English value.${length}`;
}
/**
* The model input: the HUD context, led by the summary language when it is
* not English. A small model follows the language far more reliably when it
* is part of the input as well as the instructions.
* @param {object} context The HUD summary context.
* @returns {string}
*/
export function hudSummaryInput(context) {
const tag = hudSummaryLocale(context?.locale);
const json = JSON.stringify(context ?? {});
if (tag === DEFAULT_LOCALE) return json;
return `Summary language: ${summaryLanguage(tag)} (${tag}).\n${json}`;
}
/**
* Stamp HUD summary context with the same layer snapshots voice tools use.
* @param {Array<object>} [layers] `DataLayerManager.getAll()` rows.
@@ -83,32 +256,13 @@ export function hudSummaryLayerContext(layers = [], options) {
}
/**
* The feed state and up to two layer names for the HUD telemetry suffix when
* an enabled feed is not nominal. Values stay English (`state` is a feed-state
* id, `names` are `layer.name`); the HUD translates them for display.
* The warning for the HUD telemetry line when an enabled feed is not
* nominal.
* @param {Array<object>} [layers]
* @param {{ now?: number }} [options]
* @returns {{ state: string, names: string[] }|null}
*/
export function hudTelemetryProvenance(layers = [], options) {
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
const envelope = feedProvenanceEnvelope(snapshots, options);
if (!envelope.overall || envelope.overall === 'nominal') return null;
const names = envelope.layers
.filter(
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
)
.map((s) => String(s.name || s.id || 'LAYER'))
.slice(0, 2);
return { state: envelope.overall, names };
}
/** Require the supplied non-nominal state before showing an AI summary. */
export function hudSummaryMatchesProvenance(summary, provenance) {
const state = provenance?.overall;
if (!state || state === 'nominal' || state === 'off') return true;
return String(summary || '')
.toUpperCase()
.split(/\W+/)
.includes(String(state).toUpperCase());
return provenanceTag(feedProvenanceEnvelope(snapshots, options));
}
+273 -24
View File
@@ -1,14 +1,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { Readable } from 'node:stream';
import {
HUD_SUMMARY_INSTRUCTIONS,
HUD_SUMMARY_MAX_UNSPACED_CHARS,
HUD_SUMMARY_RESPONSE_FORMAT,
HUD_SUMMARY_UNCONFIGURED_CODE,
hudSummaryInput,
hudSummaryInstructions,
hudSummaryLayerContext,
hudSummaryLocale,
hudTelemetryProvenance,
isHudSummaryUnconfigured,
keylessHudSummaryResponse,
} from './hudSummaryResponse.js';
import {
SNAPSHOT_FEED_STATES,
hudSummaryMatchesProvenance,
provenanceTag,
} from './data/layerSnapshot.js';
import { parseHudSummaryReply } from '../server/providers/openai/hud-summary.js';
import { openAiRealtimeProxy } from '../vite.config.js';
const UNCONFIGURED_PAYLOAD = {
@@ -206,46 +217,284 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', ()
);
});
test('HUD summary instructions require non-nominal feedState in the five words', () => {
test('HUD summary instructions ask for the canonical feedState as its own field', () => {
assert.match(
HUD_SUMMARY_INSTRUCTIONS,
/five words MUST include that feedState token/,
/Set feedState to feedProvenance\.overall exactly/,
);
assert.match(
HUD_SUMMARY_INSTRUCTIONS,
/STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/,
/do not present the view as live or healthy/,
);
assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/);
assert.match(
HUD_SUMMARY_INSTRUCTIONS,
/partial, do not present the coverage as complete/,
);
assert.deepEqual(HUD_SUMMARY_RESPONSE_FORMAT.schema.required, [
'summary',
'feedState',
]);
assert.deepEqual(
HUD_SUMMARY_RESPONSE_FORMAT.schema.properties.feedState.anyOf,
[{ type: 'string', enum: [...SNAPSHOT_FEED_STATES] }, { type: 'null' }],
);
assert.ok(!SNAPSHOT_FEED_STATES.includes('none'));
assert.ok(SNAPSHOT_FEED_STATES.includes('partial'));
});
test('the HUD proxy uses the shared provenance instructions', () => {
const local = readFileSync(
new URL('../server/providers/openai/hud-summary.js', import.meta.url),
'utf8',
test('the instructions name the UI language, with English as the fallback', () => {
assert.match(hudSummaryInstructions('es'), /in Spanish \(es\)/);
assert.match(hudSummaryInstructions('ru-RU'), /in Russian \(ru\)/);
assert.equal(hudSummaryInstructions('en'), HUD_SUMMARY_INSTRUCTIONS);
assert.equal(hudSummaryInstructions('xx-YY'), HUD_SUMMARY_INSTRUCTIONS);
assert.equal(hudSummaryInstructions(undefined), HUD_SUMMARY_INSTRUCTIONS);
assert.equal(hudSummaryLocale('es-MX'), 'es');
const english = { locale: 'en', placeLabels: ['Austin'] };
assert.equal(hudSummaryInput(english), JSON.stringify(english));
assert.match(
hudSummaryInput({ locale: 'ru' }),
/^Summary language: Russian \(ru\)\.\n\{/,
);
assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
assert.doesNotMatch(local, /enabled-layer text labels/);
assert.equal(hudSummaryLocale(42), 'en');
});
test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => {
const { hudSummaryMatchesProvenance } =
await import('./hudSummaryResponse.js');
assert.equal(
hudSummaryMatchesProvenance('Austin flights operating normally today', {
overall: 'stale',
test('shipped catalogs get their exact language, and CJK a character limit', () => {
assert.match(hudSummaryInstructions('pt'), /in Brazilian Portuguese \(pt\)/);
assert.match(
hudSummaryInstructions('zh-Hant'),
/in Chinese in Traditional characters \(mainland wording\) \(zh-Hant\)/,
);
assert.match(hudSummaryInstructions('zh-CN'), /in Simplified Chinese/);
assert.match(
hudSummaryInstructions('ja'),
new RegExp(`at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters`),
);
assert.doesNotMatch(hudSummaryInstructions('es'), /characters/);
assert.match(hudSummaryInstructions('es'), /names in enabledLayerLabels/);
});
let hudSummaryClient = 0;
async function postHudSummary(context, reply) {
// A fresh address per request: the per-IP limiter outlives a single test.
hudSummaryClient += 1;
const previousKey = process.env.OPENAI_API_KEY;
const previousFetch = globalThis.fetch;
process.env.OPENAI_API_KEY = 'test-key';
let sent = null;
globalThis.fetch = async (url, init) => {
sent = JSON.parse(init.body);
return {
ok: true,
status: 200,
json: async () => ({ output_text: reply }),
};
};
try {
// The handler itself: the installed route adds a same-site gate that a
// bare test request does not pass.
const { handleHudSummary: hud } =
await import('../server/providers/openai/hud-summary.js');
const req = Object.assign(
Readable.from([Buffer.from(JSON.stringify(context))]),
{
method: 'POST',
url: '/',
headers: {},
socket: { remoteAddress: `10.0.0.${hudSummaryClient}` },
},
);
const response = await new Promise((resolve, reject) => {
const res = {
statusCode: 200,
setHeader() {},
end(body = '') {
resolve({ statusCode: this.statusCode, body: JSON.parse(body) });
},
};
Promise.resolve(hud(req, res)).catch(reject);
});
return { sent, response };
} finally {
globalThis.fetch = previousFetch;
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
else process.env.OPENAI_API_KEY = previousKey;
}
}
test('an es request reaches the model in Spanish and returns the canonical state', async () => {
const { sent, response } = await postHudSummary(
{
locale: 'es',
placeLabels: ['Austin'],
feedProvenance: { overall: 'stale' },
},
JSON.stringify({
summary: 'Austin vuelos con datos antiguos',
feedState: 'stale',
}),
);
assert.match(sent.instructions, /in Spanish \(es\)/);
assert.equal(sent.text.format.name, 'hud_summary');
const [lead, json] = sent.input.split('\n');
assert.equal(lead, 'Summary language: Spanish (es).');
assert.equal(JSON.parse(json).locale, 'es');
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
summary: 'Austin vuelos con datos antiguos',
feedState: 'stale',
error: null,
});
});
test('a Japanese summary is capped in characters, not words', async () => {
const long = 'テキサス州議会議事堂周辺の航空機データは古くなっています';
const { response } = await postHudSummary(
{ locale: 'ja', feedProvenance: { overall: 'stale' } },
JSON.stringify({ summary: long, feedState: 'stale' }),
);
const shown = response.body.summary;
assert.ok(long.startsWith(shown), shown);
assert.ok([...shown].length <= HUD_SUMMARY_MAX_UNSPACED_CHARS, shown);
assert.ok([...shown].length >= 10, shown);
});
// Within the cap a reply comes back whole, in NFC: marks, in-word
// apostrophes and dots, multi-word names and Han names included.
for (const [locale, summary] of [
['ar', 'دبي حركة الطيران محدودة حاليًا'],
['ar', 'القاهرة تتبّع الرحلات الآن'],
['fr', "Vols au-dessus d'Orléans aujourd'hui partiels"],
['it', "Voli sull'aeroporto di Roma parziali"],
['fr', 'Données de vol à Paris'.normalize('NFD')],
['fr', 'Rue de Rivoli Vols en direct'],
['es', 'Times Square Nueva York Vuelos en vivo'],
['ru', 'Times Square Рейсы онлайн и суда'],
['en', 'Shibuya 渋谷 live flights overhead'],
['es', 'Vuelos sobre 東京駅 datos antiguos'],
['ru', 'Башня Тайбэй 101 台北101 рейсы'],
['ja', 'サンフランシスコ・ベイエリア'],
['ja', '東京 上空 の 航空 情報 は 古い'],
['zh-CN', 'SFO 附近 4 架 航班 数据 过期'],
['zh-Hant', 'SFO 附近 4 架 航班 數據 過期'],
]) {
test(`a ${locale} summary within the cap is kept whole: ${summary}`, () => {
const reply = JSON.stringify({ summary, feedState: null });
assert.equal(
parseHudSummaryReply(reply, locale).summary,
summary.normalize('NFC'),
);
});
}
test('a summary is capped in characters', async () => {
const { response } = await postHudSummary(
{ locale: 'de', feedProvenance: { overall: null } },
JSON.stringify({ summary: `Berlin ${'x'.repeat(200)}`, feedState: null }),
);
assert.equal(response.body.summary, 'Berlin');
});
test('a ja cap steps back to a space instead of cutting a Latin word', async () => {
const { response } = await postHudSummary(
{ locale: 'ja', feedProvenance: { overall: 'stale' } },
JSON.stringify({
summary: 'San Francisco Bay Area flights',
feedState: 'stale',
}),
);
assert.equal(response.body.summary, 'San Francisco Bay');
});
test('a ja cap keeps the text before a Latin word it would split', async () => {
const { response } = await postHudSummary(
{ locale: 'ja', feedProvenance: { overall: 'stale' } },
JSON.stringify({
summary: '東京 羽田空港周辺のFlightAware航空情報',
feedState: 'stale',
}),
);
assert.equal(response.body.summary, '東京 羽田空港周辺の');
});
test('a reply that skips the format carries no summary and no state', async () => {
const { response } = await postHudSummary(
{ locale: 'ru', feedProvenance: { overall: 'stale' } },
'Остин рейсы данные устарели сейчас',
);
assert.equal(response.body.summary, null);
assert.equal(response.body.feedState, null);
const unknown = await postHudSummary(
{ locale: 'ru', feedProvenance: { overall: 'stale' } },
JSON.stringify({ summary: 'Остин рейсы', feedState: 'fine' }),
);
assert.equal(unknown.response.body.feedState, null);
});
test("a localized summary is shown only with the HUD's own canonical state", () => {
const stale = { overall: 'stale' };
assert.equal(
hudSummaryMatchesProvenance(
{ summary: 'Остин рейсы данные устарели сейчас', feedState: 'stale' },
stale,
),
true,
);
assert.equal(
hudSummaryMatchesProvenance(
{ summary: 'Austin vuelos en vivo ahora', feedState: 'nominal' },
stale,
),
false,
);
assert.equal(
hudSummaryMatchesProvenance('Austin stale flights over downtown', {
overall: 'stale',
}),
true,
hudSummaryMatchesProvenance(
{ summary: 'Austin stale flights over downtown', feedState: null },
stale,
),
false,
);
assert.equal(
hudSummaryMatchesProvenance('Austin flights operating normally today', {
overall: 'nominal',
}),
hudSummaryMatchesProvenance(
{ summary: 'Austin flights' },
{ overall: 'partial' },
),
false,
);
assert.equal(
hudSummaryMatchesProvenance(
{ summary: 'Austin flights operating normally today' },
{ overall: 'nominal' },
),
true,
);
assert.equal(hudSummaryMatchesProvenance({ summary: 'x' }, null), true);
});
test('a partial view keeps its warning tag', () => {
const now = 1_000_000;
const layers = [
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: { partial: true, count: 4, lastUpdate: now },
},
{
id: 'vessels',
name: 'Live AIS Vessels',
enabled: true,
stats: { count: 9, lastUpdate: now },
},
];
const context = hudSummaryLayerContext(layers, { now });
assert.equal(context.feedProvenance.overall, 'partial');
assert.deepEqual(provenanceTag(context.feedProvenance), {
state: 'partial',
names: ['Live Flights'],
});
assert.deepEqual(hudTelemetryProvenance(layers, { now }), {
state: 'partial',
names: ['Live Flights'],
});
});
+4 -3
View File
@@ -562,7 +562,7 @@
"Radio volume": "مستوى صوت الراديو",
"Radio off": "الراديو متوقف",
"STATION SITE": "موقع المحطة",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "يتصل التطبيق مباشرةً بمحطة البث بعد الضغط على «تشغيل»، فيظهر لها عنوان IP الخاص بك.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "يتصل التطبيق مباشرةً بمحطة البث عند الضغط على «تشغيل» أو اختيار محطة على الكرة الأرضية أو طلب التشغيل بالصوت، فيظهر لها عنوان IP الخاص بك.",
"LOADING": "جارٍ التحميل",
"DEGRADED": "متدهور",
"STALE": "متقادم",
@@ -1146,7 +1146,6 @@
"OCEANIA": "أوقيانوسيا",
"NORTHERN OCEANIC GRID": "الشبكة المحيطية الشمالية",
"SOUTHERN OCEANIC GRID": "الشبكة المحيطية الجنوبية",
"{state} {layers}": "{state}: {layers}",
"view band|STREET": "شارع",
"view band|CITY": "مدينة",
"view band|METRO": "حضري",
@@ -3223,5 +3222,7 @@
"Audio": "الصوت",
"UTILITIES": "الأدوات",
"Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم",
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة"
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
"Sea Marks": "علامات بحرية",
"Ski Pistes": "مسارات التزلج"
}
+4 -3
View File
@@ -777,7 +777,7 @@
"Stop radio playback": "Radiowiedergabe stoppen",
"Radio volume": "Radio-Lautstärke",
"STATION SITE": "SENDER-WEBSITE",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Beim Starten der Wiedergabe wird eine direkte Verbindung zum Sender hergestellt. Die IP-Adresse ist für diesen Sender sichtbar.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Beim Starten der Wiedergabe, bei der Wahl eines Senders auf dem Globus oder auf Wunsch per Sprachsteuerung wird eine direkte Verbindung zum Sender hergestellt. Die IP-Adresse ist für diesen Sender sichtbar.",
"Intelligence HUD (H)": "Lagebild-HUD (H)",
"Layout": "Layout",
"HUD layout": "HUD-Layout",
@@ -1114,7 +1114,6 @@
"OCEANIA": "OZEANIEN",
"NORTHERN OCEANIC GRID": "NÖRDLICHES OZEANRASTER",
"SOUTHERN OCEANIC GRID": "SÜDLICHES OZEANRASTER",
"{state} {layers}": "{state} {layers}",
"view band|STREET": "STRASSE",
"view band|CITY": "STADT",
"view band|METRO": "GROSSRAUM",
@@ -2943,5 +2942,7 @@
"Audio": "Audio",
"UTILITIES": "WERKZEUGE",
"Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen",
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien"
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien",
"Sea Marks": "Seezeichen",
"Ski Pistes": "Skipisten"
}
+4 -3
View File
@@ -450,7 +450,7 @@
"Radio volume": "Volumen de Radio",
"Radio off": "Radio desactivada",
"STATION SITE": "SITIO DE LA EMISORA",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "El audio se conecta directamente con la emisora después de pulsar ESCUCHAR. Tu IP es visible para esa emisora.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "El audio se conecta directamente con la emisora al pulsar ESCUCHAR, elegir una emisora en el globo o pedirlo por voz. Tu IP es visible para esa emisora.",
"LOADING": "CARGANDO",
"DEGRADED": "DEGRADADO",
"STALE": "DESACTUALIZADO",
@@ -2021,7 +2021,6 @@
"Globe field image unavailable": "Imagen del campo en el globo no disponible",
"status chip|UNAVAILABLE": "NO DISP.",
"Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Contiene referencias a contenido registrado o medios vinculados. Esos archivos externos no se incluyen en un paquete de escena. Sus avisos originales siguen vigentes.",
"{state} {layers}": "{state}: {layers}",
"Selected Space Mission": "Misión espacial seleccionada",
"{duration}s + {hold}s": "{duration} s + {hold} s",
"Configured Source": "Fuente configurada",
@@ -3013,5 +3012,7 @@
"Right panels": "Paneles derechos",
"UTILITIES": "UTILIDADES",
"Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo",
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos"
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos",
"Sea Marks": "Señales marítimas",
"Ski Pistes": "Pistas de esquí"
}
+4 -3
View File
@@ -331,7 +331,6 @@
"STALE": "OBSOLÈTE",
"PARTIAL": "PARTIEL",
"FALLBACK": "SECOURS",
"{state} {layers}": "{state} : {layers}",
"view band|STREET": "RUE",
"view band|CITY": "VILLE",
"view band|METRO": "MÉTROPOLE",
@@ -2819,7 +2818,7 @@
"Radio volume": "Volume de la radio",
"STATION SITE": "SITE DE LA STATION",
"DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture. Votre IP est visible par ce diffuseur.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture, choisissez une station sur le globe ou la demandez par la voix. Votre IP est visible par ce diffuseur.",
"Local RTL-SDR receiver": "Récepteur RTL-SDR local",
"LOCAL RTL-SDR": "RTL-SDR LOCAL",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.",
@@ -3013,5 +3012,7 @@
"Audio": "Audio",
"UTILITIES": "OUTILS",
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers"
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
"Sea Marks": "Balisage maritime",
"Ski Pistes": "Pistes de ski"
}
+4 -3
View File
@@ -331,7 +331,6 @@
"STALE": "OBSOLETO",
"PARTIAL": "PARZIALE",
"FALLBACK": "RISERVA",
"{state} {layers}": "{state} {layers}",
"view band|STREET": "STRADA",
"view band|CITY": "CITTÀ",
"view band|METRO": "METRO",
@@ -2819,7 +2818,7 @@
"Radio volume": "Volume radio",
"STATION SITE": "SITO STAZIONE",
"DIRECTORY: RADIO BROWSER": "ELENCO: RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente dopo aver premuto play. Il tuo IP è visibile a quell'emittente.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente dopo aver premuto play, scelto una stazione sul globo o chiesto la riproduzione a voce. Il tuo IP è visibile a quell'emittente.",
"Local RTL-SDR receiver": "Ricevitore RTL-SDR locale",
"LOCAL RTL-SDR": "RTL-SDR LOCALE",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un sintonizzatore locale condiviso tra FM e il livello ADS-B locale.",
@@ -3013,5 +3012,7 @@
"Audio": "Audio",
"UTILITIES": "STRUMENTI",
"Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno",
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file"
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file",
"Sea Marks": "Segnalamenti marittimi",
"Ski Pistes": "Piste da sci"
}
+4 -3
View File
@@ -325,7 +325,6 @@
"STALE": "更新遅延",
"PARTIAL": "部分的",
"FALLBACK": "フォールバック",
"{state} {layers}": "{state} {layers}",
"view band|STREET": "街路",
"view band|CITY": "市街",
"view band|METRO": "都市圏",
@@ -2677,7 +2676,7 @@
"Radio volume": "ラジオの音量",
"STATION SITE": "放送局サイト",
"DIRECTORY: RADIO BROWSER": "ディレクトリ:Radio Browser",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "再生ボタンを押すと、音声は放送局に直接接続されます。お使いのIPアドレスは放送局側から見えます。",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "再生ボタンを押す、地球儀で局を選ぶ、または音声コントロールで再生を指示すると、音声は放送局に直接接続されます。お使いのIPアドレスは放送局側から見えます。",
"Local RTL-SDR receiver": "ローカルRTL-SDR受信機",
"LOCAL RTL-SDR": "ローカルRTL-SDR",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · FMとローカルADS-Bレイヤーで1台のローカルチューナーを共有します。",
@@ -2873,5 +2872,7 @@
"Audio": "オーディオ",
"UTILITIES": "ユーティリティ",
"Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください",
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル"
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
"Sea Marks": "航路標識",
"Ski Pistes": "スキーゲレンデ"
}
+4 -3
View File
@@ -331,7 +331,6 @@
"STALE": "DESATUALIZADO",
"PARTIAL": "PARCIAL",
"FALLBACK": "ALTERNATIVO",
"{state} {layers}": "{state}: {layers}",
"view band|STREET": "RUA",
"view band|CITY": "CIDADE",
"view band|METRO": "METRÓPOLE",
@@ -2821,7 +2820,7 @@
"Radio volume": "Volume do rádio",
"STATION SITE": "SITE DA ESTAÇÃO",
"DIRECTORY: RADIO BROWSER": "DIRETÓRIO: RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "O áudio conecta direto à emissora depois que você aperta \"Ouvir\". Seu IP fica visível para essa emissora.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "O áudio conecta direto à emissora quando você aperta \"Ouvir\", escolhe uma estação no globo ou pede por voz. Seu IP fica visível para essa emissora.",
"Local RTL-SDR receiver": "Receptor RTL-SDR local",
"LOCAL RTL-SDR": "RTL-SDR LOCAL",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · um sintonizador local compartilhado entre o FM e a camada ADS-B local.",
@@ -3013,5 +3012,7 @@
"Audio": "Áudio",
"UTILITIES": "UTILITÁRIOS",
"Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho",
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos"
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos",
"Sea Marks": "Sinais marítimos",
"Ski Pistes": "Pistas de esqui"
}
+4 -3
View File
@@ -727,7 +727,7 @@
"STATION TAG": "ТЕГ СТАНЦИИ",
"DRAG TO TUNE": "ТЯНИТЕ ДЛЯ НАСТРОЙКИ",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ПРОСМОТР · ENTER / SPACE — ВЫБОР",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "После нажатия «Пуск» звук идёт напрямую от вещателя, и ему виден ваш IP-адрес.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Звук идёт напрямую от вещателя после нажатия «Пуск», выбора станции на глобусе или голосовой команды. Вещателю виден ваш IP-адрес.",
"Internet radio and local SDR": "Интернет-радио и локальный SDR",
"Filter stations by station tag": "Фильтровать станции по тегу",
"CONTEXT": "КОНТЕКСТ",
@@ -2055,7 +2055,6 @@
"Globe field image unavailable": "Изображение поля на глобусе недоступно",
"status chip|UNAVAILABLE": "НЕДОСТ.",
"Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Используется зарегистрированный контент или связанные медиафайлы. Эти внешние файлы не входят в архив сцены. Их исходные условия использования по-прежнему действуют.",
"{state} {layers}": "{state}: {layers}",
"Selected Space Mission": "Выбранная космическая миссия",
"{duration}s + {hold}s": "{duration} с + {hold} с",
"Configured Source": "Настроенный источник",
@@ -3083,5 +3082,7 @@
"Right panels": "Правые панели",
"UTILITIES": "ИНСТРУМЕНТЫ",
"Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку",
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы"
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
"Sea Marks": "Морские знаки",
"Ski Pistes": "Горнолыжные трассы"
}
+4 -3
View File
@@ -334,7 +334,6 @@
"STALE": "ЗАСТАРІЛО",
"PARTIAL": "ЧАСТКОВО",
"FALLBACK": "РЕЗЕРВ",
"{state} {layers}": "{state}: {layers}",
"view band|STREET": "ВУЛИЦЯ",
"view band|CITY": "МІСТО",
"view band|METRO": "АГЛОМЕРАЦІЯ",
@@ -2889,7 +2888,7 @@
"Radio volume": "Гучність радіо",
"STATION SITE": "САЙТ СТАНЦІЇ",
"DIRECTORY: RADIO BROWSER": "КАТАЛОГ: RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Після натискання «Відтворити» звук надходить безпосередньо від мовника. Мовник бачить вашу IP-адресу.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Звук надходить безпосередньо від мовника після натискання «Відтворити», вибору станції на глобусі або голосової команди. Мовник бачить вашу IP-адресу.",
"Local RTL-SDR receiver": "Локальний приймач RTL-SDR",
"LOCAL RTL-SDR": "ЛОКАЛЬНИЙ RTL-SDR",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · один локальний тюнер для FM і шару «Локальний ADS-B».",
@@ -3083,5 +3082,7 @@
"Audio": "Аудіо",
"UTILITIES": "ІНСТРУМЕНТИ",
"Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка",
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли"
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
"Sea Marks": "Морські знаки",
"Ski Pistes": "Гірськолижні траси"
}
+4 -3
View File
@@ -325,7 +325,6 @@
"STALE": "过期",
"PARTIAL": "部分",
"FALLBACK": "备用",
"{state} {layers}": "{state} {layers}",
"view band|STREET": "街道",
"view band|CITY": "城市",
"view band|METRO": "都市圈",
@@ -2576,7 +2575,7 @@
"Radio volume": "电台音量",
"STATION SITE": "电台网站",
"DIRECTORY: RADIO BROWSER": "目录:RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "点击播放后,音频将直接连接到广播方。广播方可以看到你的 IP。",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "点击播放、在地球上选择电台或通过语音要求播放后,音频将直接连接到广播方。广播方可以看到你的 IP。",
"Local RTL-SDR receiver": "本地 RTL-SDR 接收机",
"LOCAL RTL-SDR": "本地 RTL-SDR",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一个本地调谐器由 FM 与本地 ADS-B 图层共享。",
@@ -2873,5 +2872,7 @@
"Right panels": "右侧面板",
"UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置",
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件"
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件",
"Sea Marks": "航标",
"Ski Pistes": "滑雪道"
}
+4 -3
View File
@@ -325,7 +325,6 @@
"STALE": "過期",
"PARTIAL": "部分",
"FALLBACK": "備用",
"{state} {layers}": "{state} {layers}",
"view band|STREET": "街道",
"view band|CITY": "城市",
"view band|METRO": "都市圈",
@@ -2564,7 +2563,7 @@
"Radio volume": "電臺音量",
"STATION SITE": "電臺網站",
"DIRECTORY: RADIO BROWSER": "目錄:RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "點擊播放後,音頻將直接連接到廣播方。廣播方可以看到你的 IP。",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "點擊播放、在地球上選擇電臺或通過語音要求播放後,音頻將直接連接到廣播方。廣播方可以看到你的 IP。",
"Local RTL-SDR receiver": "本地 RTL-SDR 接收機",
"LOCAL RTL-SDR": "本地 RTL-SDR",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一個本地調諧器由 FM 與本地 ADS-B 圖層共享。",
@@ -2873,5 +2872,7 @@
"Right panels": "右側面板",
"UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件"
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
"Sea Marks": "航標",
"Ski Pistes": "滑雪道"
}
+73 -20
View File
@@ -28,6 +28,29 @@ function services() {
};
}
// The application's own scene services, as src/app/layers/militaryFlights.js
// composes them; only the surface samplers and registry are stubbed.
async function sceneServices() {
return {
picking: await import('../../data/pickRegistry.js'),
sprites: await import('../../data/spriteOrder.js'),
trails: await import('../../data/trailRenderer.js'),
aircraftPresentation: await import('../../data/tr3bRegistry.js'),
camera: await import('../../data/trackedCamera.js'),
labels: await import('../../data/detectionDraw.js'),
geoid: await import('../../data/geoid.js'),
focus: await import('../../data/focusDeemphasis.js'),
readout: await import('../../data/trackedReadout.js'),
context: await import('../../data/contextStore.js'),
render: await import('../../renderGovernor.js'),
recession: await import('../../data/aircraftRecession.js'),
groundFloor: await import('../../data/groundFloor.js'),
groundSnap: await import('../../data/groundSnap.js'),
meshFloor: { sampleMeshFloorCells: () => {} },
militaryRegistry: { registerMilitaryIcaos: () => {} },
};
}
test('military layer instances isolate policy and restoration state without requesting a source', async () => {
let requests = 0;
const source = {
@@ -122,26 +145,7 @@ test('a newly seen military contact appears at its delayed position, not ahead o
// the next fleet tick jumped it back.
const Cesium = await import('cesium');
const { RENDER_DELAY_SEC } = await import('./policy.js');
// The application's own scene services, as src/app/layers/militaryFlights.js
// composes them; only the surface samplers and registry are stubbed.
const supplied = {
picking: await import('../../data/pickRegistry.js'),
sprites: await import('../../data/spriteOrder.js'),
trails: await import('../../data/trailRenderer.js'),
aircraftPresentation: await import('../../data/tr3bRegistry.js'),
camera: await import('../../data/trackedCamera.js'),
labels: await import('../../data/detectionDraw.js'),
geoid: await import('../../data/geoid.js'),
focus: await import('../../data/focusDeemphasis.js'),
readout: await import('../../data/trackedReadout.js'),
context: await import('../../data/contextStore.js'),
render: await import('../../renderGovernor.js'),
recession: await import('../../data/aircraftRecession.js'),
groundFloor: await import('../../data/groundFloor.js'),
groundSnap: await import('../../data/groundSnap.js'),
meshFloor: { sampleMeshFloorCells: () => {} },
militaryRegistry: { registerMilitaryIcaos: () => {} },
};
const supplied = await sceneServices();
const fixLon = -97.6;
const fixLat = 30.3;
const speedMps = 200;
@@ -213,6 +217,55 @@ test('a newly seen military contact appears at its delayed position, not ahead o
);
});
test('a vanished military contact fades while position-less rows keep theirs', async () => {
// About a third of a live adsb.lol /v2/mil fetch is contacts without a
// position. Those rows marked every snapshot partial, so an aircraft that
// left the feed stayed at full opacity for five minutes.
const Cesium = await import('cesium');
const { readsbSnapshot } = await import('../../sources/live/aircraft.js');
const nowMs = Date.now();
const row = (hex, lon) => ({
hex,
lat: 30.3,
lon,
alt_baro: 20_000,
gs: 300,
track: 90,
seen_pos: 1,
});
const polls = [
[row('ae0001', -97.6), row('ae0002', -96.6), row('ae0003', -95.6)],
[row('ae0001', -97.5), { hex: 'ae0003' }],
];
// Every poll also lists a contact that never has a position.
for (const poll of polls) poll.push({ hex: 'ae0009', type: 'mode_s' });
const layer = createMilitaryFlightLayer({
services: await sceneServices(),
source: {
label: 'Fixture aircraft',
getSnapshot: async () =>
readsbSnapshot(
{ ac: polls.shift() },
{ observedAtMs: nowMs, now: nowMs },
),
},
});
const viewer = { camera: { positionCartographic: null }, scene: {} };
layer.testing._setTrackedMilitaryRefreshStateForTest({
icao24: 'seed00',
entity: null,
meta: { rawLat: 0, rawLon: 0, onGround: false },
billboard: { show: false, position: Cesium.Cartesian3.fromDegrees(0, 0) },
billboardCollection: { show: true, add: (options) => ({ ...options }) },
viewer,
tracked: false,
});
await layer.update(viewer);
await layer.update(viewer);
assert.equal(layer.findByQuery('ae0002')?.stale, true, 'vanished: fading');
assert.equal(layer.findByQuery('ae0003')?.stale, false, 'still listed');
});
test('voice context reads a track that rounds to north as 0°, not 360°', () => {
const stubs = services();
stubs.aircraftPresentation = { tr3bTypeLabel: (id, type) => type };
+5 -2
View File
@@ -187,9 +187,12 @@ export class MilitaryFlightRecords {
fixEpochMs: aircraft.positionTimeMs ?? observedAtMs,
};
}
absence(id, { complete, likelyLanded }) {
absence(id, { complete, retainIds, likelyLanded }) {
// Partial admissions and position-less rows do not prove absence, but
// retention remains bounded; a likely-landed position-less contact
// still takes the landed limit.
if (
!complete &&
(!complete || (retainIds?.has(id) && !likelyLanded)) &&
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
)
return 'retain';
+24
View File
@@ -99,6 +99,30 @@ test('military records keep bounded partial retention and release metadata only
);
});
test('complete military snapshots age out vanished aircraft and retain position-less ones unless landed', () => {
const records = store();
for (const id of ['aaa001', 'aaa002', 'aaa003'])
records.receive(observation({ id, reference: id }), context());
// aaa001 and aaa003 are still listed without a position; aaa002 vanished.
const absent = (id, likelyLanded = false) =>
records.absence(id, {
complete: true,
retainIds: new Set(['aaa001', 'aaa003']),
likelyLanded,
});
assert.deepEqual(
[absent('aaa002'), absent('aaa002'), absent('aaa002')],
['stale', 'stale', 'remove'],
);
assert.equal(absent('aaa001'), 'retain');
assert.equal(records.missingPolls.has('aaa001'), false);
// A landed contact that lost its position takes the landed fast-cull.
assert.equal(absent('aaa003', true), 'remove');
// Position-less retention stays bounded like partial admission.
records.data.get('aaa001').observedReceiptMs = Date.now() - 300001;
assert.equal(absent('aaa001'), 'stale');
});
test('military emergency status follows the latest poll instead of sticking', () => {
const records = store();
const first = records.receive(
+1
View File
@@ -259,6 +259,7 @@ export function createMilitarySnapshotRenderer({
if (currentIcaos.has(icao24)) continue;
const absence = records.absence(icao24, {
complete: snapshot.complete,
retainIds: snapshot.retainIds,
likelyLanded: queries._likelyLanded(icao24),
});
if (absence === 'retain') continue;
+6 -3
View File
@@ -344,14 +344,17 @@ export function createPlayback({ state: layerState, services, parts, source }) {
}
if (!parts.interaction.radioPresentationAllowed())
return Promise.resolve(false);
if (!layerState._selectedId) {
if (layerState._selectedId) {
// Drop a fallback left by a voice step whose playback never started.
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
} else {
const ranked = parts.queries.rankedVisibleStations();
if (!ranked.length) return Promise.resolve(false);
layerState._playFallbackId = ranked[1]?.id || null;
layerState._playFallbackFocus = false;
parts.selection.selectRadioStation(ranked[0].id, {
autoplay: false,
focus: false,
playFallback: { id: ranked[1]?.id || null, focus: false },
});
}
return playSelectedRadio({ origin });
+46 -16
View File
@@ -73,6 +73,16 @@ export function createSelection({
return { filter: layerState._filter, volume: layerState._userVolume };
}
// A paused or failed stream of another station would outlive its selection.
function releaseStaleRadioStream(stationId) {
if (
layerState._audioStationId &&
layerState._audioStationId !== stationId &&
['paused', 'error'].includes(layerState._audioState)
)
parts.playback.stopRadioPlayback();
}
/** Select a station. Playback occurs only when autoplay is explicitly true. */
function selectRadioStation(
@@ -83,11 +93,18 @@ export function createSelection({
origin = 'programmatic',
attemptId = null,
cameraNavigation = null,
playFallback = null,
} = {},
) {
if (!parts.interaction.radioPresentationAllowed()) return false;
const station = layerState._stationById.get(String(id));
if (!station) return false;
if (!autoplay) releaseStaleRadioStream(station.id);
// Set after the release, which clears fallbacks.
if (playFallback) {
layerState._playFallbackId = playFallback.id;
layerState._playFallbackFocus = playFallback.focus;
}
layerState._tuningUnavailableStationId = null;
if (
!parts.navigation.radioCameraNavigationOwnsSelection(
@@ -104,6 +121,7 @@ export function createSelection({
)
parts.tuning.endRadioTuning();
layerState._cancelledTuningPresentationStation = null;
const changed = layerState._selectedId !== station.id;
layerState._selectedId = station.id;
const generation = ++layerState._selectionGeneration;
const sessionGeneration = layerState._sessionGeneration;
@@ -122,6 +140,9 @@ export function createSelection({
if (focus) parts.navigation.focusStation(station);
parts.presentation.emitState();
if (autoplay) void parts.playback.playSelectedRadio({ origin, attemptId });
// A user step to another station cancels a playback voice prepared.
else if (origin === 'user' && changed)
parts.presentation.emitPlaybackControl('select', origin);
return true;
}
@@ -133,6 +154,7 @@ export function createSelection({
rotate = false,
stationIds = null,
autoplay = true,
fallback = autoplay,
origin = 'programmatic',
} = {},
) {
@@ -159,25 +181,29 @@ export function createSelection({
current < 0
? 0
: (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length;
layerState._playFallbackId =
ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id : null;
// Voice defers playback, so its silent step keeps a fallback; silent UI steps do not.
const fallbackId =
fallback && ranked.length > 1
? ranked[(nextIndex + 1) % ranked.length].id
: null;
const rotationCameraState = rotate
? parts.navigation.radioCameraState()
: null;
const rotationNavigation = rotate
? parts.navigation.beginRadioCameraNavigation(rotationCameraState)
: null;
layerState._playFallbackFocus = rotate
? (fallbackStation) => {
parts.navigation.rotateRadioStationIntoView(
fallbackStation,
0.65,
rotationCameraState,
rotationNavigation,
);
return rotationNavigation;
}
: false;
const fallbackFocus =
fallback && rotate
? (fallbackStation) => {
parts.navigation.rotateRadioStationIntoView(
fallbackStation,
0.65,
rotationCameraState,
rotationNavigation,
);
return rotationNavigation;
}
: false;
const station = ranked[nextIndex];
if (rotate)
parts.navigation.rotateRadioStationIntoView(
@@ -191,6 +217,7 @@ export function createSelection({
focus: false,
origin,
cameraNavigation: rotationNavigation,
playFallback: { id: fallbackId, focus: fallbackFocus },
});
}
@@ -217,9 +244,12 @@ export function createSelection({
},
);
if (!ranked.length) return null;
layerState._playFallbackId = ranked[1]?.id || null;
layerState._playFallbackFocus = false;
selectRadioStation(ranked[0].id, { autoplay, focus: false, origin });
selectRadioStation(ranked[0].id, {
autoplay,
focus: false,
origin,
playFallback: { id: ranked[1]?.id || null, focus: false },
});
return ranked[0];
}
+2
View File
@@ -78,6 +78,8 @@ export function createState({ services }) {
layerState._tuningStartStationId = null;
layerState._tuningResumesPlayback = false;
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
layerState._tuningStationById = new Map();
+17 -5
View File
@@ -66,6 +66,12 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._cancelledTuningPresentationStation = null;
layerState._tuningStartStationId = layerState._selectedId;
layerState._tuningPreviewId = layerState._selectedId;
// Like Previous/Next, the release plays only if a stream was active.
layerState._tuningResumesPlayback = [
'loading',
'buffering',
'playing',
].includes(layerState._audioState);
parts.tuningNoise.installTuningNoise();
parts.playback.pauseRadioPlayback({ origin: 'user' });
parts.tuningNoise.syncTuningNoiseGain();
@@ -179,7 +185,9 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._selectionGeneration += 1;
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
layerState._tuningUnavailableStationId = stationId;
parts.presentation.emitState();
// The drag paused the stream; with no selection, Resume would start the
// nearest station. Stop also publishes the state and cancels a voice handoff.
parts.playback.stopRadioPlayback({ origin });
return Object.freeze({
ok: false,
reason: 'station-unavailable',
@@ -187,9 +195,11 @@ export function createTuning({ state: layerState, services, parts, source }) {
generation,
});
}
const autoplay = layerState._tuningResumesPlayback;
layerState._tuningResumesPlayback = false;
layerState._tuningActive = false;
layerState._tuningStatic = true;
layerState._tuningAwaitingStationId = frozenStation.id;
layerState._tuningStatic = autoplay;
layerState._tuningAwaitingStationId = autoplay ? frozenStation.id : null;
layerState._tuningPreviewId = null;
layerState._tuningStartStationId = null;
layerState._tuningUnavailableStationId = null;
@@ -204,10 +214,12 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._playFallbackFocus = null;
layerState._playFallbackOrigin = 'programmatic';
layerState._playFallbackAttemptId = null;
parts.tuningNoise.syncTuningNoiseGain();
// Muting alone would leave the noise graph running after a silent release.
if (autoplay) parts.tuningNoise.syncTuningNoiseGain();
else parts.tuningNoise.clearRadioTuningNoise({ emit: false });
parts.presentation.emitState();
const selected = parts.selection.selectRadioStation(frozenStation.id, {
autoplay: true,
autoplay,
focus: false,
origin,
cameraNavigation,
+22 -4
View File
@@ -50,6 +50,11 @@ export function createTuningNoise({
layerState._tuningNoiseGain = null;
}
function suspendTuningNoiseContext() {
const suspended = layerState._tuningNoiseContext?.suspend?.();
if (suspended?.catch) void suspended.catch(() => {});
}
function syncTuningNoiseGain() {
if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return;
const audible = radioTuningStaticShouldPlay({
@@ -79,8 +84,21 @@ export function createTuningNoise({
if (!AudioContextClass) return false;
if (!layerState._tuningNoiseContext)
layerState._tuningNoiseContext = new AudioContextClass();
const resumed = layerState._tuningNoiseContext.resume?.();
if (resumed?.catch) void resumed.catch(() => {});
const context = layerState._tuningNoiseContext;
const resumed = context.resume?.();
// A release before the device starts finds the context still suspended,
// so its suspend() is a no-op; suspend again once resume() settles.
if (resumed?.then)
void resumed.then(
() => {
if (
layerState._tuningNoiseContext === context &&
!layerState._tuningNoiseSource
)
suspendTuningNoiseContext();
},
() => {},
);
if (layerState._tuningNoiseSource) return true;
const frameCount = Math.max(
@@ -120,6 +138,7 @@ export function createTuningNoise({
if (layerState._tuningCameraNavigation)
parts.navigation.invalidateRadioCameraNavigation();
layerState._tuningActive = false;
layerState._tuningResumesPlayback = false;
layerState._tuningStatic = false;
layerState._tuningAwaitingStationId = null;
layerState._tuningPreviewId = null;
@@ -131,8 +150,7 @@ export function createTuningNoise({
parts.rendering.updateSelectionEntity();
syncTuningNoiseGain();
stopTuningNoiseSource();
const suspended = layerState._tuningNoiseContext?.suspend?.();
if (suspended?.catch) void suspended.catch(() => {});
suspendTuningNoiseContext();
if (emit) parts.presentation.emitState();
}
return {
+10 -7
View File
@@ -3,13 +3,16 @@
*
* The credential panel has its own stricter gate (`admitKeySetupRequest` in
* keySetupCore.mjs): loopback socket + local Host + exact Origin + JSON
* Content-Type. The four endpoints here — `/api/realtime/token` (mints an
* OpenAI Realtime token = spends money), `/api/openai/hud-summary` (OpenAI
* spend), `/api/google/nearby-places` (Google spend), and
* `/api/realtime/debug-log` (appends to a local JSONL) — were previously
* ungated, so a hostile web page open in the same browser could reach them
* cross-site: a `fetch` carries a foreign `Origin`, and an `<img>`/navigation
* carries NO `Origin` at all but does carry `Sec-Fetch-Site: cross-site`.
* Content-Type. The endpoints behind this gate — `/api/realtime/token` (mints
* an OpenAI Realtime token = spends money), `/api/openai/hud-summary` (OpenAI
* spend), `/api/google/nearby-places` (Google spend),
* `/api/realtime/debug-log` (appends to a local JSONL) and the quota-bearing
* routes SECURITY.md lists with them — were previously ungated, so a hostile
* web page open in the same browser could reach them cross-site: a `fetch`
* carries a foreign `Origin`, and an `<img>`/navigation carries NO `Origin`
* at all but does carry `Sec-Fetch-Site: cross-site`. The keyless map routes
* (`/api/terrain/heights`, `/api/overpass`) reuse this core through
* `admitSameSiteKeyless` with the forwarding-header refusal switched off.
*
* This module is the pure core that refuses those shapes while keeping the
* documented `HOST=0.0.0.0` LAN opt-in and non-browser loopback tools (the two
+19 -6
View File
@@ -151,12 +151,25 @@ export function createApplicationRequestServices({
const query = points
.map(({ lat, lon }) => `${lon.toFixed(5)},${lat.toFixed(5)}`)
.join(';');
return requireOk(
await request(`${urls.terrain}?points=${encodeURIComponent(query)}`, {
signal,
}),
'Terrain heights',
)?.results;
const response = await request(
`${urls.terrain}?points=${encodeURIComponent(query)}`,
{ signal },
);
// A failed request still carries the real heights the server had, so
// the caller can keep them; null marks a point without one.
const partial = response.data?.partial;
if (
!response.ok &&
Array.isArray(partial) &&
partial.length === points.length
) {
const error = new Error(
`Terrain heights unavailable (${response.status})`,
);
error.partial = partial;
throw error;
}
return requireOk(response, 'Terrain heights')?.results;
},
},
regional: {
+17
View File
@@ -95,6 +95,23 @@ test('a failed probe is retried after its backoff, not on every query', async ()
]);
});
test('a failed terrain request carries the real heights the server had', async () => {
const partial = [{ ellipsoid: 412.5 }, null];
const services = createApplicationRequestServices({
fetchImpl: async () => Response.json({ error: 'x', partial }, { status: 502 }),
});
const points = [{ lat: 30, lon: -97 }, { lat: 31, lon: -97 }];
await assert.rejects(services.terrain.getHeights(points), (error) => {
assert.match(error.message, /Terrain heights unavailable \(502\)/);
assert.deepEqual(error.partial, partial);
return true;
});
await assert.rejects(services.terrain.getHeights(points.slice(0, 1)), (error) => {
assert.equal(error.partial, undefined, 'a list of the wrong length is not attached');
return true;
});
});
test('the summary capability probe is asked once and unknown when unanswered', async () => {
for (const [answer, expected] of [
[() => Response.json({ configured: false }), false],
+26 -6
View File
@@ -226,7 +226,8 @@ export function createTerrainHeights({
* - Remaining (deduplicated) coordinates are sent to `/api/terrain/heights`
* in sequential chunks of <=64 points.
* - If a chunk request fails (network error, non-ok HTTP, malformed body),
* every point in THAT chunk falls back to geoid math
* every point in THAT chunk without a real height in the failure's
* `partial` falls back to geoid math
* (`sourceOrthometricM + geoidHeight` or `geoidHeight` alone) rather than
* failing the whole batch — a transient proxy outage in the middle of a
* large batch shouldn't blank out entities whose chunk happened to land
@@ -314,15 +315,34 @@ export function createTerrainHeights({
cacheStore(item.key, entry);
}
}
} catch {
} catch (error) {
requestSignal?.throwIfAborted();
// Proxy down (or cold cache had nothing to serve-stale) — fall back to
// geoid math for every point in this chunk. `ensureGeoidReady()` is
// awaited lazily, only on the fallback path, so the common (proxy
// healthy) case never pays for the geoid grid's dynamic import.
// geoid math for the points in this chunk. A failure that still
// carries real heights (the proxy's `partial`, e.g. cached points
// during an upstream 429 pause) keeps them; only the rest use the
// geoid. `ensureGeoidReady()` is awaited lazily, only on the fallback
// path, so the common (proxy healthy) case never pays for the geoid
// grid's dynamic import.
const partial =
Array.isArray(error?.partial) && error.partial.length === chunk.length
? error.partial
: [];
const fallback = [];
chunk.forEach((item, index) => {
const ellipsoid = partial[index]?.ellipsoid;
if (!Number.isFinite(ellipsoid)) {
fallback.push(item);
return;
}
const entry = { ellipsoid, source: 'reearth' };
resolvedThisCall.set(item.key, entry);
cacheStore(item.key, entry);
});
if (fallback.length === 0) continue;
await ensureGeoidReady();
requestSignal?.throwIfAborted();
for (const item of chunk) {
for (const item of fallback) {
const ellipsoid = geoidFallback(
item.lat,
item.lon,
+12 -1
View File
@@ -40,9 +40,11 @@ export function normalizeOpenSkyAircraft(row) {
};
}
const readsbId = (row) => cleanText(row?.hex).toLowerCase();
/** Normalize readsb observations. The caller supplies the source snapshot epoch. */
export function normalizeReadsbAircraft(row, snapshotTimeMs) {
const id = cleanText(row?.hex).toLowerCase();
const id = readsbId(row);
const latitude = finite(row?.lat),
longitude = finite(row?.lon);
if (!id || !coordinates(latitude, longitude)) return null;
@@ -129,6 +131,14 @@ export function openSkySnapshot(
};
}
/**
* readsb omits lat/lon for a contact heard without a current position. Both
* must be absent; a half or unreadable position still marks the row malformed.
*/
function readsbUnpositionedId(row) {
return row?.lat == null && row?.lon == null ? readsbId(row) : '';
}
export function readsbSnapshot(
payload,
{
@@ -143,6 +153,7 @@ export function readsbSnapshot(
payload?.ac,
(row) => normalizeReadsbAircraft(row, observedAtMs),
'adsb.lol',
readsbUnpositionedId,
);
const ageMs = observedAtMs == null ? null : Math.max(0, now - observedAtMs);
return {
+30 -8
View File
@@ -102,13 +102,34 @@ test('position-less OpenSky rows are seen, not missing; malformed rows stay inco
assert.throws(() => openSkySnapshot({ states: [unpositioned] }), {
code: 'malformed',
});
// Military readsb admission keeps its existing semantics.
const military = readsbSnapshot(
{ ac: [{ hex: 'abc123', lat: 30, lon: -97 }, { hex: 'def456' }] },
{ observedAtMs: null },
);
assert.equal(military.complete, false);
assert.equal(military.retainIds.size, 0);
});
test('position-less readsb rows are seen, not missing; malformed rows stay incomplete', () => {
// readsb omits lat/lon for a contact heard without a current position; a
// live adsb.lol /v2/mil fetch had 82 such rows of 239.
const positioned = { hex: 'ae638d', lat: 20.3, lon: -158.5, seen_pos: 0.2 };
const heard = { hex: 'ADFAC3', type: 'mode_s', rr_lat: 19.9, seen: 24.2 };
const lastPosition = {
hex: '3b752d',
lastPosition: { lat: 45.3, lon: 5.98, seen_pos: 61.56 },
};
const read = (ac) => readsbSnapshot({ ac }, { observedAtMs: null });
const snapshot = read([positioned, heard, lastPosition]);
assert.equal(snapshot.records.length, 1);
assert.equal(snapshot.complete, true);
assert.deepEqual([...snapshot.retainIds], ['adfac3', '3b752d']);
assert.equal(snapshot.rejectedCount, 2);
// No usable id, only half a position, an unreadable one, or no row at all.
for (const row of [
{ ...heard, hex: ' ' },
{ ...heard, lat: 30 },
{ ...heard, lon: -97 },
{ ...heard, lat: 'x', lon: 'y' },
null,
])
assert.equal(read([positioned, heard, row]).complete, false);
// A feed with no positions at all still cannot prove absence.
assert.throws(() => read([heard]), { code: 'malformed' });
});
test('readsb units and position/contact ages are normalized once against the source epoch', () => {
@@ -346,7 +367,8 @@ test('classification identities include positionless aircraft without admitting
snapshot.records.map((record) => record.id),
['def456'],
);
assert.equal(snapshot.complete, false);
assert.equal(snapshot.complete, true);
assert.deepEqual([...snapshot.retainIds], ['abc123']);
});
test('identity lookup validates its response and honors body-parse cancellation', async () => {
+99
View File
@@ -0,0 +1,99 @@
import { radioLayer } from '../data/radio.js';
export function radioStationRow(overrides = {}) {
return {
id: '00000000-0000-4000-8000-000000000001',
name: 'Station',
lat: 30,
lon: -97,
streamUrl: 'https://radio.example.com/station.mp3',
homepage: null,
tags: ['news'],
languages: ['English'],
state: 'Texas',
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: 'MP3',
bitrate: 128,
...overrides,
};
}
/**
* Run `body` against an enabled `radioLayer` that serves `stations`.
* Streams listed in `failingUrls` (mutable) reject play(); `body` receives the
* played URLs and the catalog, whose rows and generation the next update() serves.
*/
export async function withRadioLayer({ stations, failingUrls = [] }, body) {
const originalFetch = globalThis.fetch;
const originalAudio = globalThis.Audio;
const playUrls = [];
const catalog = { rows: stations, generation: 1 };
globalThis.Audio = class FakeAudio {
constructor() {
this.volume = 0.8;
this.src = '';
}
addEventListener() {}
pause() {}
play() {
playUrls.push(this.src);
if (failingUrls.includes(this.src)) {
return Promise.reject(
new DOMException('unavailable', 'NotSupportedError'),
);
}
return Promise.resolve();
}
removeAttribute() {
this.src = '';
}
load() {}
};
globalThis.fetch = async (url) =>
String(url).startsWith('/api/radio/click/')
? { ok: true }
: new Response(
JSON.stringify({
stations: catalog.rows,
stale: false,
degraded: false,
acceptedGeneration: catalog.generation,
catalogInstance: 'qa-instance-a',
updatedAt: new Date().toISOString(),
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
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 {
radioLayer.init(viewer);
radioLayer.enable();
await radioLayer.update();
await body({ playUrls, catalog });
} finally {
globalThis.fetch = originalFetch;
if (originalAudio === undefined) delete globalThis.Audio;
else globalThis.Audio = originalAudio;
radioLayer.destroy();
}
}
+22
View File
@@ -0,0 +1,22 @@
// hud.js imports `mgrs`, whose named exports Node's ESM loader cannot read:
// import this module before hud.js to swap it for a stub.
import { registerHooks } from 'node:module';
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({
resolve(specifier, context, next) {
if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true };
return next(specifier, context);
},
load(url, context, next) {
if (url === MGRS_STUB_URL) {
return {
format: 'module',
shortCircuit: true,
source:
'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
};
}
return next(url, context);
},
});
+85 -13
View File
@@ -1,11 +1,27 @@
/** A reveal must beat the one-second idle rotation refresh, regardless of FPS. */
/**
* Wall-clock budget for EACH observed frame, regardless of FPS. Both the settle
* frame (measured from the forced moveEnd) and the reveal frame (measured from
* the settle frame that hid the contact) must land inside it, so neither can be
* repaired by the one-second idle rotation refresh before it is read. The
* refresh clock starts at the fleet tick (preRender) of the frame the budget is
* measured from, so the budget must leave room for that frame's own render
* time: half the refresh interval tolerates frames up to ~500 ms, and a ~300 ms
* software-rendered frame (headless SwiftShader) fits with margin.
*/
export const ARRIVAL_OBSERVATION_MS = 500;
/** A measured rotation error at or above this fails the arrival check. */
export const ARRIVAL_MAX_ERROR_DEG = 10;
/** Attempts allowed when every earlier one timed out without a wrong angle. */
export const ARRIVAL_MAX_ATTEMPTS = 2;
/**
* Observe the first completed moveEnd frame and the first subsequent reveal.
* The deadline starts when moveEnd is forced, before its rotation baseline, so
* waiting for either frame cannot let the one-second idle refresh mask a bug.
* `read` snapshots visibility and rotation error against the current position.
* The settle deadline starts when moveEnd is forced, before its rotation
* baseline; the reveal deadline restarts at the settle frame, which is where
* the contact is hidden. `read` snapshots visibility and rotation error
* against the current position.
*/
export function observeArrivalRotation({
scene,
@@ -18,6 +34,7 @@ export function observeArrivalRotation({
}) {
return new Promise((resolve, reject) => {
const startedAt = now();
let phaseStartedAt = startedAt;
let moveEnd = null;
let moveEndMs = null;
let revealFrames = 0;
@@ -34,24 +51,42 @@ export function observeArrivalRotation({
cleanup();
resolve(result);
};
const expired = () => ({
// A timeout keeps every angle it measured: the settle angle before a
// reveal timeout, and the angle on a late frame itself (a late frame is
// read first; the idle refresh can only repair an angle, so a wrong one
// seen late was wrong on time too). `arrivalAngleViolated` then makes the
// timeout final instead of retryable.
const expired = (late = null) => ({
error: `arrival ${moveEnd ? 'reveal' : 'moveEnd'} observation exceeded ${ARRIVAL_OBSERVATION_MS} ms`,
elapsedMs: now() - startedAt,
moveEndMs,
...(moveEnd
? { dMoveEnd: moveEnd.rotationError }
: late
? { dMoveEnd: late.rotationError }
: {}),
...(moveEnd && late?.shown ? { dReveal: late.rotationError } : {}),
flipFrame: -1,
});
const armDeadline = () => {
clearTimer(timer);
timer = setTimer(() => finish(expired()), ARRIVAL_OBSERVATION_MS);
};
remove = scene.postRender.addEventListener(() => {
if (done) return;
try {
const elapsedMs = now() - startedAt;
if (elapsedMs >= ARRIVAL_OBSERVATION_MS) {
finish(expired());
const at = now();
const elapsedMs = at - startedAt;
const sample = read();
if (at - phaseStartedAt >= ARRIVAL_OBSERVATION_MS) {
finish(expired(sample));
return;
}
const sample = read();
if (moveEnd === null) {
moveEnd = sample;
moveEndMs = elapsedMs;
phaseStartedAt = at;
armDeadline();
startReveal();
} else {
revealFrames++;
@@ -76,7 +111,7 @@ export function observeArrivalRotation({
reject(error);
}
});
timer = setTimer(() => finish(expired()), ARRIVAL_OBSERVATION_MS);
armDeadline();
try {
startMoveEnd();
scene.requestRender();
@@ -87,14 +122,51 @@ export function observeArrivalRotation({
});
}
/**
* Whether an observation measured a rotation error over the threshold on any
* frame it captured, including the settle frame of a reveal that then timed
* out. Such an observation is a demonstrated failure, never inconclusive.
*/
export function arrivalAngleViolated(observation) {
const limit = (ARRIVAL_MAX_ERROR_DEG * Math.PI) / 180;
return [observation?.dMoveEnd, observation?.dReveal].some(
(error) => Number.isFinite(error) && error >= limit,
);
}
/**
* Observe arrival rotation, retrying only an inconclusive timeout. A wall-clock
* timeout means the frame landed where the idle refresh could already have
* repaired it, so it earns another attempt; an observation that measured a
* wrong angle (or succeeded) is final, so a later clean attempt can never
* replace it. `prepare` runs before every attempt; the result carries
* `attempts`.
*/
export async function observeArrivalRotationWithRetry({
prepare = async () => {},
observe = observeArrivalRotation,
maxAttempts = ARRIVAL_MAX_ATTEMPTS,
...options
}) {
let observed;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
await prepare();
observed = { ...(await observe(options)), attempts: attempt };
if (!observed.error || arrivalAngleViolated(observed)) break;
}
return observed;
}
/** Test the captured frames without retrying a bad first observation. */
export function arrivalRotationPassed(observation) {
return (
!!observation &&
!observation.error &&
observation.shown === true &&
observation.elapsedMs < ARRIVAL_OBSERVATION_MS &&
(observation.dMoveEnd * 180) / Math.PI < 10 &&
(observation.dReveal * 180) / Math.PI < 10
observation.moveEndMs < ARRIVAL_OBSERVATION_MS &&
observation.revealMs < ARRIVAL_OBSERVATION_MS &&
Number.isFinite(observation.dMoveEnd) &&
Number.isFinite(observation.dReveal) &&
!arrivalAngleViolated(observation)
);
}
+231 -8
View File
@@ -1,19 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import * as arrivalModule from './trackArrivalObservation.mjs';
import {
ARRIVAL_OBSERVATION_MS,
arrivalAngleViolated,
arrivalRotationPassed,
observeArrivalRotation,
observeArrivalRotationWithRetry,
} from './trackArrivalObservation.mjs';
function fixture() {
function fixture(start = observeArrivalRotation) {
let time = 0;
let state = { shown: true, rotationError: 0 };
const listeners = new Set();
const timers = new Map();
let renders = 0;
let moveEnds = 0;
const observation = observeArrivalRotation({
const observation = start({
scene: {
postRender: {
addEventListener(listener) {
@@ -110,7 +114,8 @@ test('no reveal fails at a deadline before the one-second idle catch-up', async
assert.equal(ARRIVAL_OBSERVATION_MS, 500);
f.frame(8, { shown: true, rotationError: 0 });
f.frame(480);
f.expire(500);
// The reveal budget is measured from the 8 ms settle frame.
f.expire(508);
const result = await f.observation;
assert.match(result.error, /reveal.*500 ms/);
assert.equal(arrivalRotationPassed(result), false);
@@ -123,20 +128,46 @@ test('no reveal fails at a deadline before the one-second idle catch-up', async
test('a late visible frame fails even if the timeout callback is delayed', async () => {
const f = fixture();
f.frame(8, { shown: true, rotationError: 0 });
f.frame(501, { shown: true, rotationError: 0 });
f.frame(509, { shown: true, rotationError: 0 });
const result = await f.observation;
assert.match(result.error, /reveal.*500 ms/);
assert.equal(result.elapsedMs, 501);
assert.equal(result.elapsedMs, 509);
assert.equal(arrivalRotationPassed(result), false);
});
test('the deadline includes moveEnd setup rather than restarting when hidden', async () => {
test('a slow software renderer passes when each frame stays inside its own budget', async () => {
// Headless SwiftShader frames run ~300 ms: settle lands at 300 ms and the
// reveal at 600 ms from moveEnd. Both stay inside the 1 s idle refresh.
const f = fixture();
f.frame(400, { shown: true, rotationError: 0 });
f.frame(300, { shown: true, rotationError: 0 });
f.expire(500); // the settle frame re-armed the deadline to 800 ms
f.frame(600, { shown: true, rotationError: 0 });
const result = await f.observation;
assert.equal(result.moveEndMs, 400);
assert.equal(result.moveEndMs, 300);
assert.equal(result.revealMs, 300);
assert.equal(result.elapsedMs, 600);
assert.equal(result.flipFrame, 1);
assert.equal(arrivalRotationPassed(result), true);
assert.equal(f.resources.timers, 0);
});
test('a settle frame later than its budget fails as a moveEnd timeout', async () => {
const f = fixture();
f.frame(500, { shown: true, rotationError: 0 });
const result = await f.observation;
assert.match(result.error, /moveEnd.*500 ms/);
assert.equal(result.moveEndMs, null);
assert.equal(arrivalRotationPassed(result), false);
});
test('a reveal later than its own budget fails even after a fast settle frame', async () => {
const f = fixture();
f.frame(400, { shown: true, rotationError: 0 });
f.frame(901, { shown: true, rotationError: 0 });
const result = await f.observation;
assert.match(result.error, /reveal.*500 ms/);
assert.equal(result.moveEndMs, 400);
assert.equal(result.elapsedMs, 901);
assert.equal(arrivalRotationPassed(result), false);
});
@@ -149,3 +180,195 @@ test('missing moveEnd frames fail without waiting for a render callback', async
assert.equal(f.resources.listeners, 0);
assert.equal(f.resources.timers, 0);
});
// Let a retry's next attempt register its postRender listener.
const settle = () => new Promise((resolve) => setImmediate(resolve));
function retryFixture(extra = {}) {
const counts = { prepares: 0 };
const f = fixture((options) =>
observeArrivalRotationWithRetry({
...options,
...extra,
async prepare() {
counts.prepares++;
},
}),
);
f.counts = counts;
return f;
}
test('a wrong settle angle followed by a reveal timeout is final, not retried into a pass', async () => {
const f = retryFixture();
await settle();
// 0.5 rad ≈ 29°: a real threshold violation on the settle frame.
f.frame(8, { shown: true, rotationError: 0.5 });
f.expire(508); // the reveal never comes inside its budget
await settle();
// An otherwise clean second attempt, which must never be started.
f.frame(516, { shown: true, rotationError: 0 });
f.frame(524, { shown: true, rotationError: 0 });
const result = await f.observation;
assert.match(result.error, /reveal.*500 ms/);
assert.equal(result.dMoveEnd, 0.5, 'the measured settle angle is kept');
assert.equal(arrivalAngleViolated(result), true);
assert.equal(result.attempts, 1);
assert.equal(f.counts.prepares, 1);
assert.equal(f.resources.moveEnds, 1);
assert.equal(arrivalRotationPassed(result), false);
assert.equal(f.resources.listeners, 0);
assert.equal(f.resources.timers, 0);
});
test('an inconclusive reveal timeout after a correct settle earns one more attempt', async () => {
const f = retryFixture();
await settle();
f.frame(8, { shown: true, rotationError: 0 });
f.expire(508);
await settle();
f.frame(516, { shown: true, rotationError: 0 });
f.frame(596, { shown: true, rotationError: 0 });
const result = await f.observation;
assert.equal(result.attempts, 2);
assert.equal(f.counts.prepares, 2);
assert.equal(f.resources.moveEnds, 2);
assert.equal(arrivalRotationPassed(result), true);
});
test('timeouts stop after the attempt limit and still fail', async () => {
const f = retryFixture();
await settle();
f.expire(500);
await settle();
f.expire(1000);
const result = await f.observation;
assert.match(result.error, /moveEnd.*500 ms/);
assert.equal(result.attempts, 2);
assert.equal(arrivalRotationPassed(result), false);
assert.equal(f.resources.listeners, 0);
assert.equal(f.resources.timers, 0);
});
test('a missing or non-finite angle never passes', () => {
const clean = {
shown: true,
moveEndMs: 8,
revealMs: 80,
dMoveEnd: 0,
dReveal: 0,
};
assert.equal(arrivalRotationPassed(clean), true);
for (const field of ['dMoveEnd', 'dReveal']) {
assert.equal(arrivalRotationPassed({ ...clean, [field]: NaN }), false);
assert.equal(
arrivalRotationPassed({ ...clean, [field]: undefined }),
false,
);
}
});
test('a wrong angle on a late reveal frame is final, not retried into a pass', async () => {
const f = retryFixture();
await settle();
f.frame(100, { shown: true, rotationError: 0 });
// Shown at 600 ms (500 ms after settle) still wearing the tampered nose.
f.frame(600, { shown: true, rotationError: 1.5 });
await settle();
f.frame(608, { shown: true, rotationError: 0 });
f.frame(688, { shown: true, rotationError: 0 });
const result = await f.observation;
assert.match(result.error, /reveal.*500 ms/);
assert.equal(result.dReveal, 1.5);
assert.equal(result.attempts, 1);
assert.equal(f.resources.moveEnds, 1);
assert.equal(arrivalRotationPassed(result), false);
});
test('a wrong angle on a late settle frame is final, not retried into a pass', async () => {
const f = retryFixture();
await settle();
f.frame(926, { shown: true, rotationError: 1.5 });
await settle();
f.frame(934, { shown: true, rotationError: 0 });
f.frame(1014, { shown: true, rotationError: 0 });
const result = await f.observation;
assert.match(result.error, /moveEnd.*500 ms/);
assert.equal(result.dMoveEnd, 1.5);
assert.equal(result.attempts, 1);
assert.equal(arrivalRotationPassed(result), false);
});
test('a late hidden reveal frame records no angle and stays retryable', async () => {
const f = retryFixture();
await settle();
f.frame(8, { shown: true, rotationError: 0 });
// Still hidden, still tampered: not a measured reveal angle.
f.frame(600, { shown: false });
const first = f.observation;
await settle();
f.frame(608, { shown: true, rotationError: 0 });
f.frame(688, { shown: true, rotationError: 0 });
const result = await first;
assert.equal(result.attempts, 2);
assert.equal(result.dReveal, 0);
assert.equal(arrivalRotationPassed(result), true);
});
test('a late frame with correct angles stays an inconclusive, retryable timeout', async () => {
const f = retryFixture();
await settle();
f.frame(926, { shown: true, rotationError: 0 });
await settle();
f.frame(934, { shown: true, rotationError: 0 });
f.frame(1014, { shown: true, rotationError: 0 });
const result = await f.observation;
assert.equal(result.attempts, 2);
assert.equal(arrivalRotationPassed(result), true);
});
test('a wrong first reveal on time is final: no retry', async () => {
const f = retryFixture();
await settle();
f.frame(8, { shown: true, rotationError: 0 });
f.frame(88, { shown: true, rotationError: 1.5 });
const result = await f.observation;
assert.equal(result.error, undefined);
assert.equal(result.attempts, 1);
assert.equal(f.resources.moveEnds, 1);
assert.equal(arrivalRotationPassed(result), false);
});
test('maxAttempts bounds inconclusive retries', async () => {
const f = retryFixture({ maxAttempts: 3 });
for (let attempt = 1; attempt <= 3; attempt++) {
await settle();
f.expire(attempt * 500);
}
const result = await f.observation;
assert.equal(result.attempts, 3);
assert.equal(f.counts.prepares, 3);
assert.equal(arrivalRotationPassed(result), false);
});
test('the 10° limit is inclusive', () => {
const limit = Math.PI / 18;
assert.equal(arrivalAngleViolated({ dMoveEnd: limit }), true);
assert.equal(arrivalAngleViolated({ dReveal: limit * 0.999 }), false);
});
test('every name the track harness imports from this module is exported', () => {
const harness = readFileSync(
new URL('../../scripts/track-regression.mjs', import.meta.url),
'utf8',
);
const names = [
...harness.matchAll(
/(?:import|const)\s*\{([^}]+)\}\s*(?:from\s*|=\s*await import\()[^;]*trackArrivalObservation\.mjs/g,
),
].flatMap((match) => match[1].split(',').map((name) => name.trim()));
assert.ok(names.includes('arrivalRotationPassed'));
assert.ok(names.includes('observeArrivalRotationWithRetry'));
for (const name of names)
assert.equal(typeof arrivalModule[name], 'function', name);
});
+23 -5
View File
@@ -1,7 +1,12 @@
/** Composite queries that combine other tools' answers for one area. */
import { suggestView } from '../views.js';
import { feedProvenanceEnvelope } from '../../data/layerSnapshot.js';
import {
feedProvenanceEnvelope,
hudSummaryMatchesProvenance,
provenanceLine,
provenanceTag,
} from '../../data/layerSnapshot.js';
import { defineTool, ToolError } from '../catalog.js';
import {
AREA_SCHEMA,
@@ -232,6 +237,7 @@ export const getHudCaption = defineTool({
feedState: sectionFeedState(brief[key]),
}),
);
const feedProvenance = feedProvenanceEnvelope(layers);
const response = await context.services.summary.summarize(
{
placeLabels: [area.label],
@@ -244,16 +250,28 @@ export const getHudCaption = defineTool({
name,
feedState,
})),
feedProvenance: feedProvenanceEnvelope(layers),
feedProvenance,
},
{ signal: context.signal },
);
const caption = response?.data?.summary;
if (!response?.ok || typeof caption !== 'string' || !caption)
const words = response?.data?.summary;
if (!response?.ok || typeof words !== 'string' || !words)
throw new ToolError('unavailable', 'The caption service did not answer');
// As on the HUD: the app's own state leads and its layers trail, and a
// reply that names another state than the app's keeps only the place.
const caption = provenanceLine(
hudSummaryMatchesProvenance(response.data, feedProvenance)
? words
: area.label,
provenanceTag(feedProvenance),
);
return {
summary: caption,
data: { area: area.label, caption },
data: {
area: area.label,
caption,
feedState: feedProvenance.overall,
},
};
},
});
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
// get_hud_caption shows the app's own feed state, not the model's words.
async function staleCaption(reply) {
const conditions = {
observedAt: '2026-10-01T19:15:00.000Z',
temperatureC: 26.3,
apparentTemperatureC: 32,
precipitationMm: 0,
cloudCoverPct: 100,
windKph: 5.4,
windDirectionDeg: 318,
visibilityM: 15400,
weatherCode: 3,
};
const places = {
resolve: async (name) =>
name === 'Austin'
? {
name: 'Austin, Texas',
bounds: { west: -97.94, south: 30.1, east: -97.56, north: 30.52 },
}
: null,
};
const staleWeather = {
getConditions: async () => ({ status: 'stale', weather: conditions }),
};
let sent = null;
const summary = {
async summarize(context) {
sent = context;
return { ok: true, status: 200, data: { error: null, ...reply } };
},
};
const catalog = composeCatalog({
tools: coreTools,
services: { weather: staleWeather, summary, places },
});
const result = await catalog.call('get_hud_caption', {
area: { place: 'Austin' },
});
assert.equal(sent.feedProvenance.overall, 'stale');
return result.data;
}
test('get_hud_caption keeps the canonical feed state of a stale view', async () => {
const data = await staleCaption({
summary: 'Austin weather across the area',
feedState: 'stale',
});
assert.equal(data.feedState, 'stale');
assert.equal(
data.caption,
'STALE | Austin weather across the area | WEATHER',
);
});
test('get_hud_caption drops words whose feed state is not the app one', async () => {
const data = await staleCaption({
summary: 'Austin live weather looks fine',
feedState: 'nominal',
});
assert.equal(data.feedState, 'stale');
assert.equal(data.caption, 'STALE | Austin, Texas | WEATHER');
});
+2
View File
@@ -43,6 +43,8 @@ export const LAYER_NAME_KEYS = Object.freeze({
'air-quality': msg('Air quality'),
directions: msg('Directions'),
'alpr-cameras': msg('ALPR Cameras'),
'sea-marks': msg('Sea Marks'),
'ski-pistes': msg('Ski Pistes'),
});
/** The layer panel's own English names, where they differ from the module's. */
+2
View File
@@ -64,6 +64,8 @@ const PANEL_GROUPS = [
'local-datacenters',
'telegeography-submarine-cables',
'local-dams',
'sea-marks',
'ski-pistes',
],
},
{
+3
View File
@@ -241,9 +241,12 @@ export function bindRadioControls() {
const pool = this._radioTunerPool.length
? this._radioTunerPool
: this._radioTunerStations;
// Silent unless a stream is active; an active stream follows the selection.
const { audioState } = this.radio.getUIState();
const cycled = this.radio.cycleStation(direction, {
rotate,
stationIds: pool.map((station) => station.id),
autoplay: ['loading', 'buffering', 'playing'].includes(audioState),
origin: 'user',
});
if (!cycled) {
+35 -1
View File
@@ -47,7 +47,9 @@ function fixture() {
publishTuner() {},
};
const calls = [];
const cycleOptions = [];
const subscribers = [];
const uiState = { audioState: 'stopped' };
const radio = {
subscribe(callback) {
subscribers.push(callback);
@@ -57,10 +59,12 @@ function fixture() {
endTuning() {
calls.push('endTuning');
},
cycleStation(direction) {
cycleStation(direction, options) {
calls.push(['cycle', direction]);
cycleOptions.push(options);
return true;
},
getUIState: () => uiState,
};
const actions = {
isRegistered: () => true,
@@ -90,6 +94,8 @@ function fixture() {
store,
actions,
calls,
cycleOptions,
uiState,
subscribers,
document,
cleanup() {
@@ -177,3 +183,31 @@ test('destruction releases an active tuner pointer after revoking listeners', ()
f.cleanup();
}
});
test('Next and Previous play only while a stream is active', () => {
const f = fixture();
try {
f.controls.connect();
for (const audioState of ['stopped', 'paused', 'error']) {
f.uiState.audioState = audioState;
f.store.act('cycleRadio', 1);
f.store.act('cycleRadio', -1);
}
assert.deepEqual(
f.cycleOptions.map((options) => options.autoplay),
[false, false, false, false, false, false],
);
f.cycleOptions.length = 0;
for (const audioState of ['loading', 'buffering', 'playing']) {
f.uiState.audioState = audioState;
f.store.act('cycleRadio', 1);
f.store.act('cycleRadio', -1);
}
assert.deepEqual(
f.cycleOptions.map((options) => options.autoplay),
[true, true, true, true, true, true],
);
} finally {
f.cleanup();
}
});
+3 -1
View File
@@ -25,7 +25,9 @@ export function renderRadioState(state) {
const interactive = enabled && !transitioning && !uncertain;
const selected = state.selected || null;
const hasStations = state.filteredCount > 0;
const activePlayback = ['playing', 'buffering'].includes(state.audioState);
const activePlayback = ['loading', 'buffering', 'playing'].includes(
state.audioState,
);
document
.getElementById('title-bar')
?.classList.toggle('radio-broadcasting', state.audioState === 'playing');
+1 -1
View File
@@ -67,7 +67,7 @@ const radioWords = computed(() => ({
<a id="radio-station-homepage" :href="radio ? radio.station.homepage || undefined : '#'" target="_blank" rel="noopener noreferrer" :hidden="radio ? !radio.station.homepage : true">{{ t('STATION SITE') }}</a>
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">{{ t('DIRECTORY: RADIO BROWSER') }}</a>
</div>
<p class="radio-privacy">{{ t('Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.') }}</p>
<p class="radio-privacy">{{ t('Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.') }}</p>
<SdrCard />
</RailPanel>
</template>
+1 -1
View File
@@ -49,7 +49,7 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// Snow leaves the set_visual_style enum.
// The Operator HUD layout leaves set_hud's layout enum.
// The layer alias hint leaves out aliases the layer id already spells.
'dc2bac1be93a503dc88d637beea2f1bc457e1f9167422bd91548b44756a8aca5',
'7f113854cdfe3114b9edd69f49995502276c718ce8878320161a68bafb63ebe2',
);
});
+6 -1
View File
@@ -2227,7 +2227,11 @@ export async function controlRadio(
if (!radioActionIsCurrent(options)) return cancelled(summarize);
const prepared = state.selected
? true
: radio.cycleStation?.(1, { focus: false, autoplay: false });
: radio.cycleStation?.(1, {
focus: false,
autoplay: false,
fallback: true,
});
return {
ok: Boolean(prepared),
action: 'control_radio',
@@ -2251,6 +2255,7 @@ export async function controlRadio(
const selected = radio.cycleStation?.(action === 'next' ? 1 : -1, {
focus: false,
autoplay: false,
fallback: true,
});
return {
ok: Boolean(selected),
+18 -1
View File
@@ -1905,7 +1905,7 @@ test('voice Radio resolves Austin and exposes semantic selection, volume, pause,
assert.equal(result.radioAction, 'play');
assert.equal(result.stationId, 'nearest');
assert.equal('station' in result, false);
assert.deepEqual(calls.at(-1), ['cycle', 1, { focus: false, autoplay: false }]);
assert.deepEqual(calls.at(-1), ['cycle', 1, { focus: false, autoplay: false, fallback: true }]);
for (const invalidCoordinates of [
{ latitude: null, longitude: null },
@@ -4032,3 +4032,20 @@ test('set_panel_open reaches the Utilities panel by its name, Draw or Import', a
}
assert.deepEqual(calls.at(-1), ['utilities-panel', false, { explicit: true }]);
});
test('a partial analyst card shows one partial chip', () => {
const card = presentResult('analyst_query', {
ok: true,
action: 'analyst_query',
count: 3,
feedState: 'partial',
partial: true,
unanswered: ['vessels'],
summary: '3 contacts',
scopeLabel: 'within 5 km of X',
display: { scope: 'within 5 km of X' },
coverage: {},
});
const labels = card.display.chips.map((chip) => chip.label);
assert.equal(new Set(labels).size, labels.length, JSON.stringify(labels));
});
+41 -32
View File
@@ -591,41 +591,50 @@ test('replacement input invalidates an in-flight Radio preflight immediately', (
assert.equal(controller.radioHandoffEpoch, priorEpoch + 1);
});
test('direct Radio pause or stop cancels a pending voice handoff', async () => {
const order = [];
let playbackControl = null;
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
runner: async () => ({ ok: true }),
radioLayer: {
subscribePlaybackControls(listener) {
playbackControl = listener;
return () => { playbackControl = null; };
for (const [name, control, message] of [
['direct Radio pause or stop cancels a pending voice handoff', 'stop', 'the explicit stop remains authoritative'],
[
'a direct Radio Next that does not play cancels a pending voice handoff',
{ action: 'select', origin: 'user', attemptId: null },
'the user selection is not overridden by voice playback',
],
]) {
test(name, async () => {
const order = [];
let playbackControl = null;
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
runner: async () => ({ ok: true }),
radioLayer: {
subscribePlaybackControls(listener) {
playbackControl = listener;
return () => { playbackControl = null; };
},
playForVoice: async () => {
order.push('radio-play');
return true;
},
stopPlayback: () => order.push('radio-stop'),
},
playForVoice: async () => {
order.push('radio-play');
return true;
},
stopPlayback: () => order.push('radio-stop'),
},
});
controller.debugLog = () => {};
controller.dc = {
readyState: 'open',
send() {},
close() { order.push('voice-stop'); },
};
controller.pendingRadioPlaybackResult = { ok: true, radioPlaybackRequested: true };
});
controller.debugLog = () => {};
controller.dc = {
readyState: 'open',
send() {},
close() { order.push('voice-stop'); },
};
controller.pendingRadioPlaybackResult = { ok: true, radioPlaybackRequested: true };
playbackControl('stop');
await controller.handleRealtimeEvent({
data: JSON.stringify({ type: 'response.done', response: { status: 'completed' } }),
});
playbackControl(control);
await controller.handleRealtimeEvent({
data: JSON.stringify({ type: 'response.done', response: { status: 'completed' } }),
});
assert.equal(controller.pendingRadioPlaybackResult, null);
assert.deepEqual(order, [], 'the explicit stop remains authoritative');
});
assert.equal(controller.pendingRadioPlaybackResult, null);
assert.deepEqual(order, [], message);
});
}
test('confirmed manual Radio playback closes active voice without stopping Radio', () => {
const order = [];
+12
View File
@@ -71,6 +71,18 @@ const layers = [
context: false,
noQuery: 'published third-party intel placemarks; not analyst records',
},
{
id: 'sea-marks',
aliases: ['seamarks', 'nautical charts', 'buoys', 'openseamap'],
context: false,
noQuery: 'raster tile overlay; no countable records',
},
{
id: 'ski-pistes',
aliases: ['ski slopes', 'ski runs', 'snow map', 'opensnowmap'],
context: false,
noQuery: 'raster tile overlay; no countable records',
},
{
id: 'flights',
track: true,
+1 -1
View File
@@ -65,7 +65,7 @@ export class RealtimeRadio {
: control || {};
if (
event.origin === 'user' &&
(event.action === 'pause' || event.action === 'stop')
['pause', 'stop', 'select'].includes(event.action)
) {
this.cancelRadioHandoff();
} else if (
+4 -1
View File
@@ -422,7 +422,10 @@ function analystCard(result) {
...(result.feedState && result.feedState !== 'nominal'
? [{ label: shownFeedState(result.feedState) }]
: []),
...(result.partial ? [{ label: t('partial') }] : []),
// A partial feed state already says it.
...(result.partial && result.feedState !== 'partial'
? [{ label: t('partial') }]
: []),
],
// Coverage qualifications lead the feed detail. The bounded card groups
// overflow in its existing Notes disclosure rather than dropping them.