mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab54675ba | ||
|
|
09d833ed96 | ||
|
|
32e08f5043 | ||
|
|
48093108b1 | ||
|
|
9faec3028e | ||
|
|
3534398771 | ||
|
|
690a6637e2 | ||
|
|
434fc9602d | ||
|
|
32ec6ed436 | ||
|
|
0c4da3cdb2 |
No files matched your search
@@ -295,8 +295,3 @@ OVERPASS_UPSTREAMS=
|
||||
# frame-ancestors sources such as "https://example.com", or * for any page.
|
||||
# Unset, no page may frame it. Other pages of the app can never be framed.
|
||||
# GEV_EMBED_FRAME_ANCESTORS=
|
||||
|
||||
# Optional keyless Radio Browser coverage: up to 8 ISO alpha-2 countries.
|
||||
# Each reserves up to 40 stations within the existing 750-station directory.
|
||||
# Restart after changing. Leave blank for the existing global selection.
|
||||
# RADIO_PRIORITY_COUNTRIES=IN,PL
|
||||
+1
-6
@@ -98,8 +98,6 @@ How to read this:
|
||||
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
|
||||
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
|
||||
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
|
||||
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
|
||||
| **OpenSnowMap** (`www.opensnowmap.org`) | Ski Pistes layer: raster overlay of ski runs and lifts (#10) | Tiles CC BY-SA; data ODbL 1.0 via OpenStreetMap; bulk tile download forbidden without prior agreement | "Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)" |
|
||||
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover |
|
||||
|
||||
### Local receiver input (not fetched from a service)
|
||||
@@ -120,7 +118,6 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
|
||||
|
||||
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
||||
- **Sea Marks and Ski Pistes (#10).** Two keyless raster overlay layers under Infrastructure, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline.
|
||||
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
|
||||
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
|
||||
<a id="gnss-integrity"></a>
|
||||
@@ -183,9 +180,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`.
|
||||
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
|
||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
|
||||
- **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query.
|
||||
|
||||
- **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
|
||||
- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
|
||||
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
|
||||
|
||||
@@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
**Local setup needs no account or API keys.** The app opens with
|
||||
**Start without an account or API keys.** Every path opens the same app with
|
||||
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
|
||||
unreachable. Flights, military traffic, satellites, earthquakes, public
|
||||
cameras, radio, and launches are available without keys.
|
||||
@@ -212,7 +212,7 @@ Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front o
|
||||
|
||||
On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`.
|
||||
|
||||
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). The map routes compare the browser's `Origin` with the `Host` the server sees, so the proxy must pass the original `Host` and, when it terminates TLS, `X-Forwarded-Proto`. Caddy does both by default; for nginx add `proxy_set_header Host $http_host;`, `proxy_set_header X-Forwarded-Proto $scheme;` and `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`. On an install outside Docker, that forwarding header also rate-limits the proxy's users apart from your own browser. Under the default Docker bridge network every client on the host, your browser included, reaches the container from its gateway address and shares one Overpass bucket.
|
||||
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -236,11 +236,7 @@ the container.
|
||||
|
||||
### Path 4 — GitHub Codespaces (cloud, no local install)
|
||||
|
||||
Codespaces requires a GitHub account. Overpass queries and OpenAI voice token
|
||||
requests return HTTP 403 through its forwarded HTTPS URL, even with API keys
|
||||
in `.env` and `GEV_ALLOWED_HOSTS` configured. Use a local setup for these features.
|
||||
|
||||
The codespace runs the dev server in a cloud workspace, so Node and the dependencies
|
||||
Runs the same dev server in a cloud workspace, so Node and the dependencies
|
||||
never touch your machine. The globe still renders in your own browser, so it
|
||||
needs the same graphics there as any other path. GitHub Free includes 120
|
||||
core-hours a month with no card on file — about 60 hours on the default 2-core
|
||||
@@ -269,13 +265,14 @@ ignored on purpose, so pass that exact name.
|
||||
|
||||
⚠️ **A codespace is reachable from the internet.** Treat it as the
|
||||
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
|
||||
Settings is unavailable on the forwarded URL. Never commit your workspace
|
||||
`.env`. [SECURITY.md](SECURITY.md) has the threat model.
|
||||
Settings does not open on the forwarded URL, so put keys in the workspace
|
||||
`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the
|
||||
threat model.
|
||||
|
||||
### Then power it up — in the app, not in a file
|
||||
|
||||
For a local setup, keys are upgrades, not prerequisites. When you want one,
|
||||
click the **POWER UP** chip in the bottom-right corner: Provider Settings lists every supported key,
|
||||
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
|
||||
chip in the bottom-right corner: Provider Settings lists every supported key,
|
||||
what it switches on, and where to get it. Paste, hit **SAVE KEYS**, and the app
|
||||
restarts itself with the new capability on. Once everything is configured the
|
||||
chip reads **POWERED UP** — and if a compact layout hides it, `?setup=1`
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights` and `/api/overpass`) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map needs both to render behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
|
||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
|
||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
- Add Sea Marks (OpenSeaMap) and Ski Pistes (OpenSnowMap) as two raster
|
||||
overlay layers under Infrastructure. They drape on the globe stacks and on
|
||||
Google 3D, and share links keep each one (Tom-Neverwinter, #114).
|
||||
@@ -1,6 +0,0 @@
|
||||
- Add optional keyless Radio priority-country coverage with up to 40 stations
|
||||
per configured country inside the existing 750-station cap; the UK alias uses
|
||||
the canonical GB code. Country query
|
||||
failures do not block a healthy global catalog. Bound mirror attempts to three
|
||||
and retry failed or partial refreshes after 60 seconds on all installations,
|
||||
including those without priority countries configured (m3ayush, #1206).
|
||||
@@ -1,13 +0,0 @@
|
||||
- Keep Overpass-backed layers working behind a reverse proxy: `/api/overpass`
|
||||
now uses the keyless same-site gate, admits forwarding headers and still
|
||||
refuses cross-site requests. Direct loopback requests keep their own
|
||||
admission window; proxied requests, including those a local reverse proxy
|
||||
relays over loopback, are admitted as remote traffic keyed by their socket,
|
||||
and IPv6 peers are keyed by /64, so neither LAN peers nor proxied users can
|
||||
exhaust the owner's admission. The upstream budget stays shared, so an owner
|
||||
cache miss can still be rate-limited (asturksever, #1308).
|
||||
- Give the tracking arrival check a wall-clock budget per observed frame and
|
||||
retry only an inconclusive timeout: a timeout keeps every angle it
|
||||
measured, including a late frame's, and a wrong one fails without retry, so slow software-rendered
|
||||
frames no longer fail the check and a real violation is never retried into a
|
||||
pass (asturksever, #1308).
|
||||
@@ -1,5 +0,0 @@
|
||||
- Military aircraft that leave the adsb.lol feed fade and are removed again.
|
||||
The feed's routine position-less rows no longer mark every snapshot partial,
|
||||
which kept vanished aircraft at full opacity for up to five minutes; those
|
||||
rows keep only their own aircraft, and a likely-landed one still leaves on
|
||||
the landed limit (Aditya, @Rohilalala, #1435).
|
||||
@@ -1,3 +0,0 @@
|
||||
- A terrain request the proxy can only partly answer (a 429 pause, or a point
|
||||
the upstream returns without a height) now keeps the real heights it has
|
||||
instead of putting the whole chunk on the geoid (quanticsoul4772, #1456).
|
||||
@@ -1,5 +0,0 @@
|
||||
- Radio Next, Previous and the tuning dial no longer start a broadcaster stream
|
||||
before Play is pressed; while a stream plays, it follows the new station.
|
||||
Play reads Pause while a stream connects, and a tuner release onto a station
|
||||
the directory removed or changed no longer leaves a Resume for another
|
||||
station (gekh, #1474).
|
||||
@@ -1,3 +0,0 @@
|
||||
- Write the AI HUD summary in the interface language, led by the HUD's own
|
||||
translated feed-state warning. A partial layer no longer reads as off or
|
||||
nominal in the HUD, voice and analyst answers (German, @gekh, #1488).
|
||||
@@ -0,0 +1,5 @@
|
||||
- One world clock now drives everything with a time of its own: the sky,
|
||||
the HUD clock, satellites, launches and weather. A timeline along the
|
||||
bottom of the screen, shown while one of those layers is on, plays, pauses
|
||||
and scrubs it, with a tick for each launch or frame and a way back to
|
||||
live. Share links made away from live carry the time (samehkhamis, #1512).
|
||||
@@ -1,3 +0,0 @@
|
||||
- The Minimal HUD layout no longer draws the centred MGRS / LAT / LON banner
|
||||
across the globe; the bottom-left readout still shows the same position, and
|
||||
now sits clear of the map attribution row instead of overlapping it (Said Turksever, #1523).
|
||||
+48
-116
@@ -263,14 +263,6 @@ that names Operator restores the default Tactical layout. Operator was the
|
||||
only layout that showed the HUD's top banner bar, so its markup and styles are
|
||||
removed too.
|
||||
|
||||
Minimal no longer shows the centred MGRS / LAT / LON bar near the bottom of the
|
||||
globe (November 3, 2026, #1523): the bottom-left readout already carries the
|
||||
same position, and the bar sat across the globe. That readout now sits 7.25rem
|
||||
(plus 2vh) up, clear of the Cesium / Esri attribution row it used to overlap,
|
||||
with Scope on or off. `scripts/qa-scope-hud.mjs` checks the hidden banner and
|
||||
clearance above the rendered attribution row at desktop and intermediate
|
||||
widths in both Scope modes, plus the existing compact phone treatment.
|
||||
|
||||
## Cyber HUD — September 30, 2026
|
||||
|
||||
Display > HUD > Layout includes Cyber, also available through the HUD voice
|
||||
@@ -1205,12 +1197,12 @@ addition to the locally accumulated trail, never a promise of complete coverage.
|
||||
Snapshot coverage, completeness and freshness are separate fields. A partially
|
||||
admitted aircraft snapshot retains absent contacts for up to five minutes before
|
||||
the usual missed-poll eviction. An OpenSky row with an ICAO24 address but null
|
||||
longitude and latitude, or a readsb row with a hex but no `lat`/`lon`, is a
|
||||
contact without a position, not a malformed row: it leaves the snapshot complete
|
||||
and is listed in `retainIds`, so civil and military flights retain that aircraft
|
||||
under the same five-minute bound, unless it is likely landed, while other absent
|
||||
aircraft fade and leave on the missed-poll limits. A nonempty snapshot with no
|
||||
positioned rows is still malformed. Unknown snapshot times remain unknown in stats. An invalid nonempty
|
||||
longitude and latitude is a contact without a position, not a malformed row: it
|
||||
leaves the snapshot complete and is listed in `retainIds`, so civil flights
|
||||
retain that aircraft under the same five-minute bound, unless it is likely
|
||||
landed, while other absent aircraft fade and leave on the missed-poll limits. A
|
||||
nonempty snapshot with no positioned rows is still malformed. Military readsb
|
||||
admission is unchanged. Unknown snapshot times remain unknown in stats. An invalid nonempty
|
||||
snapshot retains the previous display. Empty vessel refreshes retain the existing
|
||||
first-connect grace and warm-data behavior. Known source failures have bounded
|
||||
messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
|
||||
@@ -3100,10 +3092,9 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> take over under the Cockpit cap of 60. Loading/capped contacts remain 2D,
|
||||
> out-of-range contacts use rotation-free dots, and exit restores map treatment.
|
||||
> Its Radio popover exposes compact power,
|
||||
> transport, station, and volume controls. Cockpit Previous/Next changes the
|
||||
> selection without starting the map-view station flights that compete with
|
||||
> the first-person camera; like the Context Previous/Next buttons, it plays
|
||||
> (with broadcaster fallback) only while a stream is already active. These
|
||||
> transport, station, and volume controls. Cockpit Previous/Next preserves
|
||||
> selection, autoplay, and broadcaster fallback audio without starting the
|
||||
> map-view station flights that compete with the first-person camera. These
|
||||
> are a Cockpit-only accordion:
|
||||
> each static header uses the left accordion's label and divider with a dedicated
|
||||
> directional-chevron disclosure button and no full-row hover treatment. Only one
|
||||
@@ -3266,25 +3257,16 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> outlasts its retries, the proxy pauses upstream calls for the `Retry-After`
|
||||
> (60 seconds when none, at most 10 minutes). A request whose points are all
|
||||
> cached, fresh or stale, is still served; a request with any uncached point
|
||||
> gets 502, whose `partial` lists the real heights it has (null for the rest)
|
||||
> when it has any, and the page keeps those and uses its geoid fallback only
|
||||
> for the points without one. The same holds when the upstream answers a
|
||||
> point with a null height. Each request
|
||||
> gets 502 and the page uses its geoid fallback for that chunk. Each request
|
||||
> that was waiting on the upstream when the 429 came back logs one warning;
|
||||
> requests refused during the pause do not log. Client geoid
|
||||
> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the
|
||||
> first later successful fetch. Smaller chunks reduce timeout risk; complete
|
||||
> camera batches still wait for their sequential requests, and unresolved
|
||||
> placement continues to use the existing prior until real heights arrive.
|
||||
> - **Overpass cache admission:** `/api/overpass` refuses cross-site POSTs
|
||||
> through the keyless same-site gate (reverse-proxy forwarding headers are
|
||||
> admitted, as for `/api/terrain/heights`), then limits requests to 300/min
|
||||
> per peer — IPv6 peers keyed by /64 — and 900/min across all remote peers
|
||||
> before reading or validating bodies. Direct loopback (no forwarding
|
||||
> headers) has its own 300/min window outside that global; a local reverse
|
||||
> proxy's requests carry forwarding headers and are counted as remote, keyed
|
||||
> `proxied:<socket peer>`, so they cannot spend the owner's window. Upstream
|
||||
> quota stays shared, so owner cache misses can still hit 429.
|
||||
> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded
|
||||
> POSTs through the shared local-request gate, then limits requests to 300/min
|
||||
> per socket peer and 900/min globally before reading or validating bodies.
|
||||
> Tag-filter brackets are scanned once; nested or unmatched brackets return 400.
|
||||
> It then checks fresh memory, identical in-flight work, and fresh disk entries
|
||||
> before invoking its separate local 90/min upstream limiter. Cache and
|
||||
@@ -4008,33 +3990,31 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
|
||||
requests use Play; a qualified Play-shaped tool call is normalized to Select so
|
||||
its criteria cannot be silently ignored.
|
||||
|
||||
| Layer | Source | File | Proxy | Update Interval |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
||||
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
|
||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
||||
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
||||
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
||||
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
|
||||
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
|
||||
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||
| Layer | Source | File | Proxy | Update Interval |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
||||
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
|
||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
||||
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
||||
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
||||
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
|
||||
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
|
||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||
|
||||
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
|
||||
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
|
||||
@@ -4724,9 +4704,8 @@ programmatic activation do not trigger this reveal.
|
||||
|
||||
Radio directory admission is atomic on both sides of the proxy boundary. A
|
||||
refresh is healthy only when it reaches the minimum accepted-query and station
|
||||
coverage from broad/specialist queries; their schema-valid responses with zero
|
||||
normalized stations count as failed queries rather than inflating refresh health.
|
||||
Optional priority-country outcomes do not gate catalog health. Each specialist query also needs
|
||||
coverage; schema-valid responses with zero normalized stations count as failed
|
||||
queries rather than inflating refresh health. Each specialist query also needs
|
||||
an accepted station whose normalized tags match that requested category; rows
|
||||
tagged only for another category remain usable catalog data but do not earn
|
||||
specialist health credit. A partial cold result remains
|
||||
@@ -4757,12 +4736,10 @@ accepted snapshot, and feed telemetry before re-initialization; monotonic
|
||||
ownership tokens plus the session boundary keep late callbacks from a retired
|
||||
session inert.
|
||||
A tuner drag resolves previews against the single accepted snapshot captured at
|
||||
pointer-down, even while a newer catalog is admitted. Like Previous/Next,
|
||||
release starts playback only if a stream was active when the drag began;
|
||||
otherwise it just selects the station. It commits only when the current same-ID
|
||||
record still exactly matches the frozen presentation and stream metadata.
|
||||
Removal or replacement reports the channel as unavailable and never silently
|
||||
retargets the drag. A cold degraded fallback may
|
||||
pointer-down, even while a newer catalog is admitted. Release starts playback
|
||||
only when the current same-ID record still exactly matches the frozen
|
||||
presentation and stream metadata; removal or replacement reports the channel as
|
||||
unavailable and never silently retargets the drag. A cold degraded fallback may
|
||||
still populate the directory and globe, but its null accepted generation cannot
|
||||
populate or begin the tuner.
|
||||
|
||||
@@ -4939,9 +4916,7 @@ before the later control succeeds, only its playback handoff is suppressed; its
|
||||
result reads enabled state from the manager and audio state from the Radio
|
||||
module. Voice Pause is a playback-only no-op while the layer is disabled; it
|
||||
never enables Radio and reports a fulfilled-false pause as failure. A
|
||||
newer-response or user-origin Stop still cancels stale handoff work, as do a
|
||||
user Pause, a user Previous/Next that only selects another station, and a tuner
|
||||
release onto a station the directory removed or changed mid-drag.
|
||||
newer-response or user-origin Stop still cancels stale handoff work.
|
||||
Only a newer user turn or session teardown aborts the complete active-tool set.
|
||||
A cancelled turn cannot publish a late Radio playback request. Dedicated Radio
|
||||
controls and generic layer-visibility commands both forward cancellation.
|
||||
@@ -5430,11 +5405,8 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
### AI HUD Summary (June 2026)
|
||||
|
||||
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
|
||||
- Input: place/street/nearby-place labels, enabled layers and `feedProvenance`, with layer names as the user sees them; outside English the narration note and per-layer state ids are left out (`overall` carries the state). No model call without a place, street or nearby-place label: the deterministic line is shown.
|
||||
- Language: the HUD sends `locale` (part of the dedupe signature); the proxy keeps a shipped locale, English otherwise. Switching language in place drops the reply and aborts the request in flight, so the next tick asks again.
|
||||
- Reply: `{summary, feedState}` (`text.format` JSON schema, `feedState` a snapshot state or null). A view that is not nominal needs the matching `feedState`. The HUD adds its own translated tag, state first and layer names last (`STALE | … | LIVE FLIGHTS`), so the warning is never clipped. Outside English a reply falls back when it carries a feed-state id, a translated layer's English name or id, only Latin letters in a non-Latin language, or, with every feed healthy, the UNAVAILABLE word. `get_hud_caption` uses the same check and tag order, in English, and returns `feedState`.
|
||||
- The model is asked for at most five words; the proxy keeps up to 80 characters, or 20 for Japanese and Chinese, cut at a word boundary unless the first word is longer. It does not count words, since the model reads a name like Rue de Rivoli as one. Errors and the 5 s timeout fall back to the deterministic line; a camera move during typing shows that line instead of the fragment.
|
||||
- `partial` is a feed state of its own, ranked between `fallback` and `nominal`, so one partial layer, or partial plus nominal, reads partial, not off or nominal (HUD, voice view state, `analyst_query`, `brief`, the agent world packet). Its narration note says coverage is incomplete rather than not live.
|
||||
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
|
||||
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
|
||||
- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable.
|
||||
|
||||
### Place-search providers
|
||||
@@ -5719,7 +5691,7 @@ pins and share links already supply complete boxes.
|
||||
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
|
||||
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
|
||||
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
|
||||
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the settle frame and then the first reveal frame, each within a 500 ms wall-clock budget that sits inside the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. Only an inconclusive timeout is retried (once); a timeout keeps every angle it measured (the settle frame before a reveal timeout, and the late frame itself, which is read before the deadline check), so a measured wrong angle fails without retry. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
||||
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the first reveal within a bounded wall-clock window before the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
||||
|
||||
### TR-3B conversion Easter egg (August 2026)
|
||||
|
||||
@@ -6327,46 +6299,6 @@ Existing catalog validation, category filters, tuning and voice playback behavio
|
||||
remain unchanged. Audio connects directly to the broadcaster after an explicit
|
||||
play action; the source does not relay or record streams.
|
||||
|
||||
### Radio priority-country coverage
|
||||
|
||||
Optional `RADIO_PRIORITY_COUNTRIES` is a server-owned comma-separated list of
|
||||
up to eight ISO alpha-2 codes (for example `IN,PL`). Restart after changing it.
|
||||
The `UK` alias is canonicalized to `GB` before deduplication and querying.
|
||||
It needs no API key and adds no POWER UP entry. An empty list preserves the
|
||||
existing global/tag-based directory. Invalid configuration fails at startup.
|
||||
|
||||
Each country adds one Radio Browser search capped at 200 rows, with existing
|
||||
HTTPS/geolocation/codec validation and an additional matching-country check.
|
||||
Up to 40 unique accepted stations per country are seeded before the specialist
|
||||
tag queries and global popularity fill, within the unchanged 750-station cap.
|
||||
No country gets exhaustive coverage. A valid, in-limit country response with
|
||||
zero eligible stations succeeds with zero selected stations.
|
||||
The response's `coverage.priorityCountries` reports accepted query status and
|
||||
selected counts when configured. The existing broad/specialist health policy
|
||||
still applies separately. Country outcomes are advisory: failed, malformed or
|
||||
oversized country queries report unsuccessful coverage but cannot degrade or
|
||||
block admission of an otherwise healthy global catalog. Queries share the existing
|
||||
three-worker pool, single-flight refresh and 45-minute cache.
|
||||
Structurally malformed rows are skipped and counted in the upstream
|
||||
`rejectedStationCount` diagnostic; mixed country responses retain their valid
|
||||
rows, while an entirely malformed nonempty country response reports unsuccessful
|
||||
coverage. The diagnostic does not block catalog admission.
|
||||
|
||||
For every installation, including those with no priority countries configured,
|
||||
at most three mirrors are tried per request; fallback mirrors beyond the first
|
||||
three candidates are not attempted. Failed or partial catalog refreshes retry
|
||||
after one minute, so a cold-start failure can continue returning 503 for that
|
||||
minute even if upstream recovers sooner. Healthy catalogs retain the 45-minute
|
||||
cache. The seven-day last-good expiry and known-station-only click reporting
|
||||
remain in force.
|
||||
|
||||
Validation: `node --test src/data/radioProxy.test.mjs` covers configuration,
|
||||
country quotas under global popularity pressure, duplicate records, concurrency,
|
||||
cache reuse, country mismatch/oversize/empty/ineligible responses, failure isolation on cold
|
||||
and warm refreshes beyond seven days, and retry/backoff recovery both with and
|
||||
without configured countries. `node scripts/qa-radio.mjs --url http://localhost:4173` covers the existing
|
||||
750-station browser presentation and playback contract.
|
||||
|
||||
## Local RTL-SDR and Local ADS-B
|
||||
|
||||
The Radio panel holds a Local RTL-SDR card below internet radio. One browser
|
||||
|
||||
@@ -119,6 +119,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
|
||||
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
|
||||
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
|
||||
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
|
||||
| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z |
|
||||
|
||||
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
|
||||
so v1 never writes it; a later view kind writes its code.
|
||||
@@ -132,6 +133,10 @@ style also extends the pinned style-defaults table.
|
||||
`hudVariant` code 1 (`operator`) is retired: the app no longer offers that
|
||||
layout and restores it as `tactical`. The code stays reserved.
|
||||
|
||||
Field 28 is the world time the link was made at, written only when it was away
|
||||
from live. A link without it opens live. Unlike field 8 it may lie ahead of the
|
||||
link's creation (a forecast moment), and it opens paused at that time.
|
||||
|
||||
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
|
||||
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||
names a glyph this list is missing, because a missing glyph does not draw
|
||||
a box — the ligature never forms and the element renders the literal word. -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,play_arrow,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
|
||||
"./application": "./src/app/application.js",
|
||||
"./application/viewer": "./src/app/viewer.js",
|
||||
"./time": "./src/time/worldClock.js",
|
||||
"./build/vite": {
|
||||
"node": "./build/vite.js"
|
||||
},
|
||||
|
||||
@@ -1102,7 +1102,6 @@
|
||||
"scripts/qa-anime-sky.mjs",
|
||||
"scripts/qa-sky-model-lighting.mjs",
|
||||
"server/providers/overpass/requestAdmission.test.mjs",
|
||||
"src/tooling/trackArrivalObservation.mjs",
|
||||
"src/tooling/trackArrivalObservation.test.mjs",
|
||||
"src/ui/styles/status-badge.css",
|
||||
"src/ui/cctvBadges.test.mjs",
|
||||
@@ -1111,5 +1110,98 @@
|
||||
"src/cameraGlobeRotation.test.mjs",
|
||||
"src/voice/globeResetConfirmation.test.mjs",
|
||||
"src/hudDms.test.mjs",
|
||||
"server/providers/common/rateLimitKeys.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/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"
|
||||
]
|
||||
@@ -149,10 +149,19 @@
|
||||
"src/ui/views/panels/GlobeCompass.vue",
|
||||
"src/ui/stores/recording.js",
|
||||
"src/ui/styleParameters.js",
|
||||
"src/ui/i18n.js"
|
||||
"src/ui/i18n.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/ui/kit/UiIcon.vue",
|
||||
"src/ui/stores/worldTime.js",
|
||||
"src/ui/views/panels/WorldTimeline.vue"
|
||||
],
|
||||
"external": ["vue", "pinia", "vue-i18n"]
|
||||
},
|
||||
"time": {
|
||||
"exports": ["./time"],
|
||||
"modules": ["src/time/worldClock.js"],
|
||||
"external": []
|
||||
},
|
||||
"viewer": {
|
||||
"exports": ["./application/viewer"],
|
||||
"modules": [
|
||||
@@ -485,7 +494,6 @@
|
||||
"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",
|
||||
@@ -1124,7 +1132,8 @@
|
||||
"src/ui/kit/UiBadge.vue",
|
||||
"src/ui/stores/globeActions.js",
|
||||
"src/ui/stores/recording.js",
|
||||
"src/ui/i18n.js"
|
||||
"src/ui/i18n.js",
|
||||
"src/time/worldClock.js"
|
||||
],
|
||||
"external": ["cesium", "vue", "pinia", "vue-i18n"]
|
||||
},
|
||||
@@ -1335,7 +1344,9 @@
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/unitLabels.js"
|
||||
"src/ui/unitLabels.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/time/julian.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1391,7 +1402,9 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/time/worldClock.js",
|
||||
"src/time/julian.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1576,7 +1589,8 @@
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/unitLabels.js"
|
||||
"src/ui/unitLabels.js",
|
||||
"src/time/worldClock.js"
|
||||
],
|
||||
"external": ["cesium", "satellite.js"]
|
||||
},
|
||||
@@ -1616,7 +1630,8 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/time/worldClock.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1936,7 +1951,8 @@
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js"
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||
"src/time/worldClock.js"
|
||||
],
|
||||
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
||||
},
|
||||
@@ -2353,7 +2369,6 @@
|
||||
"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",
|
||||
@@ -3177,6 +3192,9 @@
|
||||
"src/ui/stores/globeActions.js",
|
||||
"src/ui/stores/recording.js",
|
||||
"src/ui/i18n.js",
|
||||
"src/time/viewerClock.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/time/julian.js",
|
||||
"src/annotations/outlineRungs.js",
|
||||
"src/sources/openFreeMapOutlines.js",
|
||||
"src/sources/nominatimOutlines.js",
|
||||
@@ -3284,7 +3302,6 @@
|
||||
"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",
|
||||
@@ -3785,6 +3802,8 @@
|
||||
"src/ui/stores/bhoteKoshiPanel.js",
|
||||
"src/hudReadouts.js",
|
||||
"src/ui/i18n.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/time/julian.js",
|
||||
"src/annotations/outlineRungs.js",
|
||||
"src/sources/openFreeMapOutlines.js",
|
||||
"src/sources/nominatimOutlines.js",
|
||||
@@ -3944,7 +3963,8 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/time/worldClock.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -4463,7 +4483,8 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/time/worldClock.js"
|
||||
],
|
||||
"external": ["cesium", "egm96-universal", "pbf"]
|
||||
},
|
||||
@@ -4515,7 +4536,8 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/time/worldClock.js"
|
||||
],
|
||||
"external": ["cesium", "pbf"]
|
||||
},
|
||||
@@ -4635,7 +4657,8 @@
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/scopeMaskPolicy.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/visualStylePolicy.js"
|
||||
"src/visualStylePolicy.js",
|
||||
"src/time/worldClock.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
|
||||
+3
-20
@@ -3073,27 +3073,20 @@ async function main() {
|
||||
expectedSlot: targetIndex,
|
||||
previewPlayDelta: previewPlays - playsBefore,
|
||||
commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore,
|
||||
audioState: state.audioState,
|
||||
tuningStatic: state.tuningStatic,
|
||||
samples,
|
||||
spread: Math.max(...samples) - Math.min(...samples),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'from a stopped Radio, pointer release only selects the nearest station, silently and without a needle sweep',
|
||||
'pointer preview never plays and release commits the nearest station once without a needle sweep',
|
||||
tunerDirectRelease.targetId
|
||||
&& tunerDirectRelease.selectedId === tunerDirectRelease.targetId
|
||||
&& tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot
|
||||
&& tunerDirectRelease.previewPlayDelta === 0
|
||||
&& tunerDirectRelease.commitPlayDelta === 0
|
||||
&& tunerDirectRelease.audioState === 'stopped'
|
||||
&& !tunerDirectRelease.tuningStatic
|
||||
&& tunerDirectRelease.commitPlayDelta === 1
|
||||
&& tunerDirectRelease.spread < 0.5,
|
||||
JSON.stringify(tunerDirectRelease),
|
||||
);
|
||||
// The release plays only if a stream was active when the drag began.
|
||||
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
|
||||
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||
// Earlier cases scroll and resize the directory. Bring the physical drag
|
||||
// target back into its scroller before deriving viewport mouse coordinates.
|
||||
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
|
||||
@@ -3501,9 +3494,6 @@ async function main() {
|
||||
});
|
||||
});
|
||||
await sleep(500);
|
||||
// Next left the Radio stopped; a release plays only from an active stream.
|
||||
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
|
||||
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||
const failedTuner = await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const slider = document.getElementById('radio-tuner-slider');
|
||||
@@ -3702,14 +3692,7 @@ async function main() {
|
||||
radio.stopPlayback();
|
||||
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
|
||||
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
|
||||
// Next plays only while a stream is active, so start from a playing station.
|
||||
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: true, focus: false });
|
||||
});
|
||||
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||
await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const camera = gev.viewer.camera;
|
||||
const radio = gev.dataManager.layers.get('radio').module;
|
||||
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
|
||||
const pool = gev.styleManager._radioControls._radioTunerPool;
|
||||
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
|
||||
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
* Optional: QA_BASE_URL, QA_SCREENSHOT_DIR (screenshots stay outside the repo).
|
||||
* Proves a fresh boot defaults to Scope ON at 100%, native checkbox click/Space,
|
||||
* feather 0/11/100/200, layout independence,
|
||||
* share/scene restore, visible text backing including rotated metadata,
|
||||
* and Minimal's banner removal and coordinate/attribution clearance.
|
||||
* share/scene restore, and visible text backing including rotated metadata.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -667,79 +666,6 @@ try {
|
||||
await settleTextBackings(false);
|
||||
checkSceneChrome(`narrow ${layout}`, before, await readSceneChrome());
|
||||
}
|
||||
await page.select('#hud-layout-select', 'minimal');
|
||||
for (const viewport of [
|
||||
{ width: 1600, height: 900 },
|
||||
{ width: 1024, height: 700 },
|
||||
{ width: 820, height: 700 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
await page.setViewport(viewport);
|
||||
for (const enabled of [true, false]) {
|
||||
await page.evaluate(
|
||||
(enabled) =>
|
||||
window.__godsEyeView.styleManager.applyVisualState({
|
||||
scope: { enabled },
|
||||
}),
|
||||
enabled,
|
||||
);
|
||||
await settleTextBackings(enabled);
|
||||
// Wait for the responsive shell and the real attribution logo to settle.
|
||||
await page.waitForFunction(
|
||||
(mobile) =>
|
||||
document.body.classList.contains('mobile-shell') === mobile &&
|
||||
[...document.querySelectorAll('#cesium-credits img')].some(
|
||||
(img) => img.complete && img.naturalWidth > 0,
|
||||
),
|
||||
{},
|
||||
viewport.width <= 720,
|
||||
);
|
||||
const geometry = await page.evaluate(() => {
|
||||
const banner = document.querySelector('#intel-hud .hud-bottom-bar');
|
||||
const coordinates = document.querySelector(
|
||||
'#intel-hud .hud-bottom-left',
|
||||
);
|
||||
// The fixed host has zero size; Cesium positions its actual row inside it.
|
||||
const credits = document.querySelector(
|
||||
'#cesium-credits .cesium-widget-credits',
|
||||
);
|
||||
const coordinateBox = coordinates.getBoundingClientRect();
|
||||
const creditBox = credits.getBoundingClientRect();
|
||||
return {
|
||||
bannerHidden: getComputedStyle(banner).display === 'none',
|
||||
coordinatesVisible:
|
||||
coordinateBox.width > 0 && coordinateBox.height > 0,
|
||||
coordinatesHidden: getComputedStyle(coordinates).display === 'none',
|
||||
creditsVisible: creditBox.width > 0 && creditBox.height > 0,
|
||||
gap: creditBox.top - coordinateBox.bottom,
|
||||
bottom: getComputedStyle(coordinates).bottom,
|
||||
};
|
||||
});
|
||||
const name = `Minimal ${viewport.width}px Scope ${enabled ? 'ON' : 'OFF'}`;
|
||||
check(
|
||||
`${name}: centred coordinate banner stays hidden`,
|
||||
geometry.bannerHidden,
|
||||
geometry,
|
||||
);
|
||||
if (viewport.width <= 720) {
|
||||
check(
|
||||
`${name}: phone keeps its compact HUD treatment`,
|
||||
geometry.coordinatesHidden &&
|
||||
geometry.creditsVisible &&
|
||||
geometry.bottom === 'auto',
|
||||
geometry,
|
||||
);
|
||||
} else {
|
||||
check(
|
||||
`${name}: coordinate block clears the full attribution row`,
|
||||
geometry.coordinatesVisible &&
|
||||
geometry.creditsVisible &&
|
||||
geometry.gap >= 7.5,
|
||||
geometry,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
check('runtime has no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
||||
import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
||||
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
||||
|
||||
@@ -2698,18 +2698,16 @@ async function main() {
|
||||
// Staged deterministically: park the camera nadir over the synthetic
|
||||
// cluster, let rotations settle, TAMPER a billboard's rotation, then
|
||||
// trigger each path with the camera idle. The fleet runs every 80 ms,
|
||||
// independently of rendered FPS. Observe the first completed settle frame
|
||||
// within 500 ms of forcing moveEnd and the first reveal frame within
|
||||
// 500 ms of that settle frame: each budget sits inside the 1 s idle
|
||||
// rotation refresh that could otherwise repair an incorrect first reveal,
|
||||
// and a ~300 ms headless SwiftShader frame fits inside each of them.
|
||||
// independently of rendered FPS. Observe the first completed settle and
|
||||
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
|
||||
// rotation refresh can repair an incorrect first reveal.
|
||||
// ============================================================
|
||||
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
|
||||
const arrival = await evalPage(async () => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
|
||||
const { observeArrivalRotationWithRetry } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
|
||||
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
|
||||
// 3D models OFF for this phase: a model-handed-off billboard is hidden
|
||||
// and skips rotation updates entirely — the probes need live billboards
|
||||
// (this is also the app's default state the field report came from).
|
||||
@@ -2771,23 +2769,7 @@ async function main() {
|
||||
const bb = findBB(id);
|
||||
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
|
||||
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
|
||||
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
||||
// A wall-clock timeout is inconclusive, not a wrong angle: the frame
|
||||
// landed where the 1 s idle refresh could already have repaired it
|
||||
// (seen once on headless SwiftShader: a 926 ms first frame). Such an
|
||||
// observation gets one more attempt; one that measured a wrong angle —
|
||||
// a wrong first reveal, or a wrong settle frame before a reveal
|
||||
// timeout — never does.
|
||||
const observed = await observeArrivalRotationWithRetry({
|
||||
async prepare() {
|
||||
// Freshen both layers' rotation passes right before the probe, so
|
||||
// the settle frame is measured against a pass that just ran rather
|
||||
// than one left over from the previous probe — with the camera
|
||||
// idle, a layer otherwise runs a pass only every
|
||||
// ROTATION_REFRESH_MS.
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
await nextFrames(1);
|
||||
},
|
||||
const observed = await observeArrivalRotation({
|
||||
scene: v.scene,
|
||||
startMoveEnd() {
|
||||
// Only moveEnd can repair the tampered angle at the idle pose.
|
||||
@@ -2808,6 +2790,7 @@ async function main() {
|
||||
};
|
||||
},
|
||||
});
|
||||
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
||||
return { r0, ...observed, hasModel };
|
||||
};
|
||||
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
|
||||
@@ -2817,8 +2800,8 @@ async function main() {
|
||||
military: await probe('bbb101', militaryCourse),
|
||||
};
|
||||
});
|
||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; ${arrivalAngleViolated(a) ? 'angle violation (not retried); ' : ''}${Number.isFinite(a.dMoveEnd) ? `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° ` : ''}${Number.isFinite(a.dReveal) ? `reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° ` : ''}elapsed=${Number(a.elapsedMs).toFixed(1)} ms attempts=${a.attempts}`
|
||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms attempts=${a.attempts} hasModel=${a.hasModel}`);
|
||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
|
||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`);
|
||||
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
|
||||
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isIPv6 } from 'node:net';
|
||||
import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
||||
export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
||||
|
||||
@@ -61,46 +60,3 @@ export function makeCostRateLimiter(envValue, defaultPerMin) {
|
||||
export function clientKey(req) {
|
||||
return String(req.socket?.remoteAddress || 'local');
|
||||
}
|
||||
|
||||
const IPV4_MAPPED_RE = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
|
||||
|
||||
/**
|
||||
* Whether a socket peer address is this machine: IPv4 loopback in any of its
|
||||
* forms (`127.0.0.0/8`, including the IPv4-mapped `::ffff:127.x` a dual-stack
|
||||
* listener reports) or the IPv6 loopback. Non-address keys are not loopback.
|
||||
* @param {string} address - A `clientKey` value.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLoopbackPeer(address) {
|
||||
const raw = String(address || '');
|
||||
const v4 = IPV4_MAPPED_RE.exec(raw)?.[1] ?? raw;
|
||||
return v4.startsWith('127.') || raw === '::1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate-limit key that survives address rotation, for limiters whose global
|
||||
* backstop everyone shares. IPv6 privacy extensions hand one host a fresh
|
||||
* address at will inside its /64, so keying by the full address lets a single
|
||||
* host mint per-peer quota until the global window closes for everyone else.
|
||||
* IPv6 peers are keyed by their /64 prefix instead, and an IPv4-mapped
|
||||
* address folds to its IPv4 form so one host cannot hold two buckets. Any
|
||||
* other value (IPv4, or the `local` fallback) passes through unchanged.
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @returns {string}
|
||||
*/
|
||||
export function clientNetworkKey(req) {
|
||||
const address = clientKey(req);
|
||||
const mapped = IPV4_MAPPED_RE.exec(address);
|
||||
if (mapped) return mapped[1];
|
||||
const bare = address.replace(/%.*$/, ''); // link-local zone id
|
||||
if (!isIPv6(bare)) return address;
|
||||
const [head, tail = ''] = bare.split('::');
|
||||
const headParts = head ? head.split(':') : [];
|
||||
const tailParts = tail ? tail.split(':') : [];
|
||||
const missing = Math.max(0, 8 - headParts.length - tailParts.length);
|
||||
const hextets = [...headParts, ...Array(missing).fill('0'), ...tailParts];
|
||||
return `${hextets
|
||||
.slice(0, 4)
|
||||
.map((h) => h.toLowerCase().replace(/^0+(?=.)/, ''))
|
||||
.join(':')}::/64`;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { clientNetworkKey, isLoopbackPeer } from './rate-limit.js';
|
||||
|
||||
const req = (remoteAddress) => ({ socket: { remoteAddress } });
|
||||
|
||||
test('clientNetworkKey keys IPv6 peers by /64 and folds IPv4-mapped peers', () => {
|
||||
assert.equal(clientNetworkKey(req('2001:db8:1:2::1')), '2001:db8:1:2::/64');
|
||||
assert.equal(
|
||||
clientNetworkKey(req('2001:0db8:0001:0002:abcd:ef01:2345:6789')),
|
||||
'2001:db8:1:2::/64',
|
||||
);
|
||||
assert.equal(clientNetworkKey(req('2001:DB8::1')), '2001:db8:0:0::/64');
|
||||
assert.equal(clientNetworkKey(req('fe80::1%en0')), 'fe80:0:0:0::/64');
|
||||
assert.equal(clientNetworkKey(req('::1')), '0:0:0:0::/64');
|
||||
assert.equal(clientNetworkKey(req('::ffff:10.0.0.5')), '10.0.0.5');
|
||||
assert.equal(clientNetworkKey(req('10.0.0.5')), '10.0.0.5');
|
||||
assert.equal(clientNetworkKey(req(undefined)), 'local');
|
||||
assert.equal(clientNetworkKey(req('not-an-address')), 'not-an-address');
|
||||
});
|
||||
|
||||
test('isLoopbackPeer recognizes every loopback form and nothing else', () => {
|
||||
for (const address of ['127.0.0.1', '127.9.9.9', '::1', '::ffff:127.0.0.1']) {
|
||||
assert.equal(isLoopbackPeer(address), true, address);
|
||||
}
|
||||
for (const address of [
|
||||
'10.0.0.1',
|
||||
'::ffff:10.0.0.1',
|
||||
'2001:db8::1',
|
||||
'::2',
|
||||
'local',
|
||||
'',
|
||||
undefined,
|
||||
]) {
|
||||
assert.equal(isLoopbackPeer(address), false, String(address));
|
||||
}
|
||||
});
|
||||
@@ -58,7 +58,7 @@ function forwardedProtocol(req) {
|
||||
|
||||
/**
|
||||
* Cross-site gate for keyless routes the map needs in order to render
|
||||
* (`/api/terrain/heights`, `/api/overpass`). It refuses the same cross-site browser shapes as
|
||||
* (`/api/terrain/heights`). It refuses the same cross-site browser shapes as
|
||||
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
|
||||
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
|
||||
* those protect credentials and spend, which a keyless route has neither of,
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { SNAPSHOT_FEED_STATES } from '../../../src/data/layerSnapshot.js';
|
||||
import {
|
||||
HUD_SUMMARY_MAX_CHARS,
|
||||
HUD_SUMMARY_MAX_UNSPACED_CHARS,
|
||||
HUD_SUMMARY_RESPONSE_FORMAT,
|
||||
hudSummaryInput,
|
||||
hudSummaryInstructions,
|
||||
hudSummaryIsUnspaced,
|
||||
hudSummaryLocale,
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
keylessHudSummaryResponse,
|
||||
} from '../../../src/hudSummaryResponse.js';
|
||||
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
@@ -25,58 +18,14 @@ function extractOpenAiResponseText(data) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Parse the summary and normalize the canonical feed state. */
|
||||
function parseHudSummaryReply(text, locale) {
|
||||
let reply = null;
|
||||
try {
|
||||
reply = JSON.parse(text);
|
||||
} catch {
|
||||
/* not JSON: no summary */
|
||||
}
|
||||
const feedState = SNAPSHOT_FEED_STATES.includes(reply?.feedState)
|
||||
? reply.feedState
|
||||
: null;
|
||||
return {
|
||||
summary: toHudSummary(reply?.summary, locale),
|
||||
feedState,
|
||||
};
|
||||
}
|
||||
|
||||
function toHudSummary(value, requested) {
|
||||
const locale = hudSummaryLocale(requested);
|
||||
const words = String(value || '')
|
||||
.normalize('NFC')
|
||||
// Marks, and apostrophes and dots inside a word, belong to it: ar vowel
|
||||
// signs, fr d'Orléans, ja サンフランシスコ・ベイエリア.
|
||||
.replace(/[^\p{L}\p{M}\p{N}\s'’·・-]/gu, ' ')
|
||||
function toFiveWordHudSummary(value) {
|
||||
return String(value || '')
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((word) => word.replace(/^['’·・-]+|['’·・-]+$/gu, ''))
|
||||
.filter(Boolean);
|
||||
// Characters, not words: the model counts Rue de Rivoli as one word.
|
||||
const max = hudSummaryIsUnspaced(locale)
|
||||
? HUD_SUMMARY_MAX_UNSPACED_CHARS
|
||||
: HUD_SUMMARY_MAX_CHARS;
|
||||
return capAtWord(words.join(' '), max, locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut `text` to `max` characters at a word boundary; a first word longer
|
||||
* than that is cut where the cap falls.
|
||||
*/
|
||||
function capAtWord(text, max, locale) {
|
||||
const graphemes = [
|
||||
...new Intl.Segmenter(locale, { granularity: 'grapheme' }).segment(text),
|
||||
];
|
||||
if (graphemes.length <= max) return text;
|
||||
const cut = graphemes[max].index;
|
||||
let end = 0;
|
||||
for (const { index, segment } of new Intl.Segmenter(locale, {
|
||||
granularity: 'word',
|
||||
}).segment(text)) {
|
||||
if (index + segment.length > cut) break;
|
||||
end = index + segment.length;
|
||||
}
|
||||
return text.slice(0, end).trim() || text.slice(0, cut);
|
||||
.filter(Boolean)
|
||||
.slice(0, 5)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
async function handleHudSummary(req, res) {
|
||||
@@ -128,18 +77,14 @@ async function handleHudSummary(req, res) {
|
||||
model:
|
||||
process.env.OPENAI_HUD_SUMMARY_MODEL ||
|
||||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
||||
instructions: hudSummaryInstructions(context?.locale),
|
||||
input: hudSummaryInput(context),
|
||||
text: { format: HUD_SUMMARY_RESPONSE_FORMAT },
|
||||
instructions: HUD_SUMMARY_INSTRUCTIONS,
|
||||
input: JSON.stringify(context),
|
||||
reasoning: { effort: 'minimal' },
|
||||
max_output_tokens: 200,
|
||||
max_output_tokens: 100,
|
||||
}),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const { summary, feedState } = parseHudSummaryReply(
|
||||
extractOpenAiResponseText(data),
|
||||
context?.locale,
|
||||
);
|
||||
const summary = toFiveWordHudSummary(extractOpenAiResponseText(data));
|
||||
res.statusCode = response.ok && summary ? 200 : response.status || 502;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
@@ -148,7 +93,6 @@ async function handleHudSummary(req, res) {
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
summary: summary || null,
|
||||
feedState,
|
||||
// Never relay `data.error.message`: that is OpenAI's own wording, and
|
||||
// it carries request ids, organization hints and quota phrasing.
|
||||
error: response.ok ? null : 'OpenAI HUD summary request failed',
|
||||
@@ -166,4 +110,4 @@ async function handleHudSummary(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
export { handleHudSummary, parseHudSummaryReply };
|
||||
export { handleHudSummary };
|
||||
@@ -1,12 +1,6 @@
|
||||
import {
|
||||
makeRateLimiter,
|
||||
clientKey,
|
||||
clientNetworkKey,
|
||||
isLoopbackPeer,
|
||||
} from './common/rate-limit.js';
|
||||
import { hasProxySignals } from '../../src/localRequestGate.mjs';
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import { readRequestBodyCapped } from './common/request.js';
|
||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||
import { admitSameSite } from './common/same-site.js';
|
||||
import {
|
||||
OVERPASS_MAX_BODY_BYTES,
|
||||
OVERPASS_MAX_CONCURRENT,
|
||||
@@ -35,21 +29,10 @@ const _overpassInFlight = new Map();
|
||||
let _overpassConcurrent = 0;
|
||||
|
||||
// Bound body-reading and validation work, including malformed requests and
|
||||
// cache hits, before a byte is read. Remote peers share one global window so
|
||||
// address rotation cannot mint admission without bound (IPv6 peers are keyed
|
||||
// by /64 for the same reason). A direct loopback request — the owner's own
|
||||
// browser and the repo QA harnesses, with no forwarding headers — has its own
|
||||
// per-peer window outside that global, so busy LAN peers cannot lock the owner
|
||||
// out of the map. A request relayed by a reverse proxy on this machine also
|
||||
// arrives from loopback, but it is remote traffic: it is admitted from the
|
||||
// remote pool under its own socket-keyed bucket, never from the owner's. The
|
||||
// forwarded client address is not trusted as identity. Only admission is
|
||||
// isolated: the upstream budget below (90/min per key, 300/min globally) is
|
||||
// shared, so an owner request that misses cache can still be refused there.
|
||||
const OVERPASS_ADMISSION_WINDOW = { windowMs: 60_000, max: 300 };
|
||||
const _overpassLoopbackAdmission = makeRateLimiter(OVERPASS_ADMISSION_WINDOW);
|
||||
const _overpassPeerAdmission = makeRateLimiter({
|
||||
...OVERPASS_ADMISSION_WINDOW,
|
||||
// cache hits. Keep the tighter upstream budget below independent of this gate.
|
||||
const _overpassRequestLimiter = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: 300,
|
||||
globalMax: 900,
|
||||
});
|
||||
|
||||
@@ -59,33 +42,6 @@ const _overpassRateLimiter = makeRateLimiter({
|
||||
globalMax: 300,
|
||||
});
|
||||
|
||||
/**
|
||||
* Classify a request for admission. Direct loopback — a loopback socket with
|
||||
* no forwarding header — is the owner's own pool; everything else, including a
|
||||
* loopback socket that carries forwarding headers (a local reverse proxy), is
|
||||
* remote. A proxied request keeps its socket identity under a `proxied:`
|
||||
* prefix, so it never shares a bucket with the owner's direct requests.
|
||||
* @param {import('http').IncomingMessage} req
|
||||
* @returns {{direct: boolean, key: string}}
|
||||
*/
|
||||
function overpassAdmissionClass(req) {
|
||||
const peer = clientNetworkKey(req);
|
||||
if (!isLoopbackPeer(clientKey(req))) return { direct: false, key: peer };
|
||||
if (hasProxySignals(req.headers || {}))
|
||||
return { direct: false, key: `proxied:${peer}` };
|
||||
return { direct: true, key: peer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend one admission slot for this request; false when its bucket is over its
|
||||
* window (or, for remote traffic, when the shared global window is full).
|
||||
* @param {{direct: boolean, key: string}} admission - `overpassAdmissionClass(req)`.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function admitOverpassRequest({ direct, key }) {
|
||||
return (direct ? _overpassLoopbackAdmission : _overpassPeerAdmission)(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a completed Overpass payload to the HTTP response.
|
||||
*
|
||||
@@ -147,13 +103,8 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keyless gate: refuses cross-site browser requests but, unlike the
|
||||
// cost-bearing routes, admits the forwarding headers a reverse proxy
|
||||
// adds — the layers that depend on Overpass must keep working for an
|
||||
// instance deployed the way the README documents.
|
||||
if (admitSameSiteKeyless(req, res)) return;
|
||||
const admission = overpassAdmissionClass(req);
|
||||
if (!admitOverpassRequest(admission)) {
|
||||
if (admitSameSite(req, res)) return;
|
||||
if (!_overpassRequestLimiter(clientKey(req))) {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
@@ -212,7 +163,7 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
// requests; boundary-class queries keep their month-long TTL.
|
||||
readDisk: () =>
|
||||
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
||||
allowUpstream: () => _overpassRateLimiter(admission.key),
|
||||
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
|
||||
loadUpstream: async () => {
|
||||
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT)
|
||||
return {
|
||||
|
||||
@@ -122,14 +122,12 @@ function sanitizeOverpassBody(rawBody) {
|
||||
const boundedSets = new Set();
|
||||
for (let stmt of withoutTags.split(';')) {
|
||||
stmt = stmt.trim();
|
||||
// The settings block (`[out:json][timeout:25]`) was blanked together with
|
||||
// the tag filters, so it arrives here as an empty statement.
|
||||
if (!stmt || /^out\b/.test(stmt)) continue;
|
||||
if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue;
|
||||
|
||||
// Strip output-set assignments (NOT input bounds). With the tag filters
|
||||
// already gone, a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot
|
||||
// be misread as a spatial bound; bounds live in (...) / function calls /
|
||||
// set refs, never inside [...], so nothing real was lost.
|
||||
// Strip output-set assignments (NOT input bounds). Bracket tag filters were
|
||||
// removed above so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot be
|
||||
// misread as a spatial bound. Bounds live in (...) / function calls / set
|
||||
// refs, never inside [...], so the probe loses nothing real.
|
||||
const outSets = [];
|
||||
const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
|
||||
outSets.push(name);
|
||||
@@ -168,10 +166,8 @@ function sanitizeOverpassBody(rawBody) {
|
||||
if (bounded) for (const name of outSets) boundedSets.add(name);
|
||||
}
|
||||
|
||||
// Tolerate whitespace around the directive so `[ timeout : N ]` is clamped
|
||||
// rather than passed through as written.
|
||||
const clamped = data.replace(
|
||||
/\[\s*timeout\s*:\s*(\d+)\s*\]/gi,
|
||||
/\[timeout:\s*(\d+)\s*\]/gi,
|
||||
(_, n) =>
|
||||
`[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`,
|
||||
);
|
||||
|
||||
@@ -3,24 +3,10 @@ import assert from 'node:assert/strict';
|
||||
import { Readable } from 'node:stream';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { overpassProxy } from '../overpass.js';
|
||||
import { _overpassCache } from './cache.js';
|
||||
import { sanitizeOverpassBody } from './query.js';
|
||||
|
||||
const form = (query) => `data=${encodeURIComponent(query)}`;
|
||||
|
||||
// The limiters are module-level, so every test that spends admission mocks a
|
||||
// clock a full day past the previous one in execution order: no test can see
|
||||
// another's window, whatever order (or randomization) they run in. A new
|
||||
// test that reaches admission must call it too: earlier tests leave
|
||||
// timestamps days ahead of the real clock, and those still count.
|
||||
let clockBase = Date.now() + 86_400_000;
|
||||
function freshClock(t) {
|
||||
clockBase += 86_400_000;
|
||||
const clock = { now: clockBase };
|
||||
t.mock.method(Date, 'now', () => clock.now);
|
||||
return clock;
|
||||
}
|
||||
|
||||
test('bracket validation rejects malformed filters without trusting their contents as bounds', () => {
|
||||
for (const query of [
|
||||
'node[',
|
||||
@@ -51,15 +37,6 @@ test('bounded queries preserve quoted brackets, comments, multiple tags and time
|
||||
query.replace('[timeout:999]', '[timeout:30]'),
|
||||
);
|
||||
}
|
||||
// Whitespace inside the directive is clamped too, not passed through.
|
||||
const spaced = sanitizeOverpassBody(
|
||||
form('[out:json][ timeout : 999 ];node(around:20,1,1);out;'),
|
||||
);
|
||||
assert.equal(spaced.ok, true);
|
||||
assert.equal(
|
||||
new URLSearchParams(spaced.body).get('data'),
|
||||
'[out:json][timeout:30];node(around:20,1,1);out;',
|
||||
);
|
||||
});
|
||||
|
||||
test('a burst of maximum-size unterminated filters completes within a bounded process deadline', () => {
|
||||
@@ -149,13 +126,7 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
{ 'sec-fetch-site': 'cross-site' },
|
||||
{ 'sec-fetch-site': 'same-site' },
|
||||
{ origin: 'null' },
|
||||
// Forwarding headers do not launder a cross-site request.
|
||||
{ 'x-forwarded-for': '10.0.0.9', 'sec-fetch-site': 'cross-site' },
|
||||
{
|
||||
'x-forwarded-proto': 'https',
|
||||
origin: 'http://localhost:4173',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
},
|
||||
{ 'x-forwarded-for': '127.0.0.1' },
|
||||
]) {
|
||||
const response = await call(route, { headers, forbidRead: true });
|
||||
assert.equal(response.statusCode, 403);
|
||||
@@ -165,24 +136,12 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
});
|
||||
}
|
||||
|
||||
test('same-origin, LAN, proxied and non-browser requests still reach validation; GET status stays available', async (t) => {
|
||||
freshClock(t);
|
||||
test('same-origin, LAN and non-browser requests still reach validation; GET status stays available', async () => {
|
||||
const route = handler();
|
||||
for (const headers of [
|
||||
{},
|
||||
{ origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' },
|
||||
{ host: '192.168.1.10:4173', origin: 'http://192.168.1.10:4173' },
|
||||
// The README's reverse-proxy deployment: Overpass is keyless, so the
|
||||
// forwarding headers the proxy adds are admitted (as for terrain heights).
|
||||
{
|
||||
host: 'globe.example.lan',
|
||||
origin: 'https://globe.example.lan',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-forwarded-for': '203.0.113.7',
|
||||
'x-forwarded-proto': 'https',
|
||||
'x-forwarded-host': 'globe.example.lan',
|
||||
},
|
||||
{ 'x-forwarded-for': '127.0.0.1' },
|
||||
]) {
|
||||
const response = await call(route, { headers });
|
||||
assert.equal(response.statusCode, 400);
|
||||
@@ -199,7 +158,8 @@ test('same-origin, LAN, proxied and non-browser requests still reach validation;
|
||||
});
|
||||
|
||||
test('invalid requests spend admission quota before parsing, including on an unconfigured server', async (t) => {
|
||||
const clock = freshClock(t);
|
||||
let now = Date.now() + 120000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
@@ -213,216 +173,6 @@ test('invalid requests spend admission quota before parsing, including on an unc
|
||||
assert.equal(denied.reads, 0);
|
||||
assert.equal(denied.headers['retry-after'], '60');
|
||||
assert.equal((await call(route, { peer: 'other-peer' })).statusCode, 400);
|
||||
clock.now += 60_001;
|
||||
now += 60001;
|
||||
assert.equal((await call(route, { peer })).statusCode, 400);
|
||||
});
|
||||
|
||||
test('the 900/min global window spans non-loopback peers only; loopback keeps its own window', async (t) => {
|
||||
const clock = freshClock(t);
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
const route = handler();
|
||||
for (const peer of ['10.0.0.1', '10.0.0.2', '10.0.0.3']) {
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal((await call(route, { peer })).statusCode, 400, peer);
|
||||
}
|
||||
}
|
||||
const starved = await call(route, { peer: '10.0.0.4', forbidRead: true });
|
||||
assert.equal(starved.statusCode, 429, 'global window is full for LAN peers');
|
||||
assert.equal(starved.reads, 0);
|
||||
for (const peer of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) {
|
||||
const owner = await call(route, { peer });
|
||||
assert.equal(owner.statusCode, 400, `${peer} is admitted past the global`);
|
||||
assert.equal(owner.reads, 1);
|
||||
}
|
||||
clock.now += 60_001;
|
||||
assert.equal((await call(route, { peer: '10.0.0.4' })).statusCode, 400);
|
||||
});
|
||||
|
||||
test('IPv6 peers share a /64 admission bucket and IPv4-mapped peers fold to IPv4', async (t) => {
|
||||
const clock = freshClock(t);
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
const route = handler();
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal(
|
||||
(await call(route, { peer: `2001:db8:1:2::${(i % 200) + 1}` }))
|
||||
.statusCode,
|
||||
400,
|
||||
);
|
||||
}
|
||||
const rotated = await call(route, {
|
||||
peer: '2001:db8:1:2:ffff:ffff:ffff:9',
|
||||
forbidRead: true,
|
||||
});
|
||||
assert.equal(rotated.statusCode, 429, 'a rotating host stays in one bucket');
|
||||
assert.equal(
|
||||
(await call(route, { peer: '2001:db8:1:3::1' })).statusCode,
|
||||
400,
|
||||
);
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal(
|
||||
(await call(route, { peer: '::ffff:10.1.1.1' })).statusCode,
|
||||
400,
|
||||
);
|
||||
}
|
||||
const unmapped = await call(route, { peer: '10.1.1.1', forbidRead: true });
|
||||
assert.equal(unmapped.statusCode, 429, 'mapped and bare IPv4 share a bucket');
|
||||
clock.now += 60_001;
|
||||
assert.equal((await call(route, { peer: '10.1.1.1' })).statusCode, 400);
|
||||
});
|
||||
|
||||
test('cache hits spend admission budget but not the 90/min upstream budget', async (t) => {
|
||||
const clock = freshClock(t);
|
||||
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
|
||||
t.after(() => {
|
||||
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
|
||||
else process.env.OVERPASS_UPSTREAMS = prior;
|
||||
_overpassCache.delete(hitKey);
|
||||
});
|
||||
let fetches = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
fetches++;
|
||||
// Not data: nothing is cached or written to disk by this test.
|
||||
return new Response('upstream unavailable', { status: 503 });
|
||||
});
|
||||
const hit = 'node(around:20,1,1);out;';
|
||||
const hitKey = `data=${encodeURIComponent(hit)}`;
|
||||
_overpassCache.set(hitKey, {
|
||||
status: 200,
|
||||
body: '{"elements":[]}',
|
||||
contentType: 'application/json',
|
||||
endpoint: 'https://overpass.example/api/interpreter',
|
||||
cachedAt: clock.now,
|
||||
});
|
||||
const route = handler();
|
||||
const peer = '10.7.7.7';
|
||||
// More cache hits than the upstream limiter would allow per peer.
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const response = await call(route, { peer, body: form(hit) });
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.headers['x-overpass-cache'], 'HIT');
|
||||
}
|
||||
assert.equal(fetches, 0);
|
||||
const miss = await call(route, {
|
||||
peer,
|
||||
body: form('node(around:20,2,2);out;'),
|
||||
});
|
||||
assert.equal(fetches, 1, 'the first miss still reaches upstream');
|
||||
assert.notEqual(miss.statusCode, 429);
|
||||
for (let i = 0; i < 199; i++) {
|
||||
assert.equal(
|
||||
(await call(route, { peer, body: form(hit) })).statusCode,
|
||||
200,
|
||||
);
|
||||
}
|
||||
const exhausted = await call(route, {
|
||||
peer,
|
||||
body: form(hit),
|
||||
forbidRead: true,
|
||||
});
|
||||
assert.equal(exhausted.statusCode, 429, 'hits spent the admission window');
|
||||
assert.equal(exhausted.reads, 0);
|
||||
});
|
||||
|
||||
test('a local reverse proxy cannot spend the direct-loopback reserve; owner cache hits survive it', async (t) => {
|
||||
const clock = freshClock(t);
|
||||
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
|
||||
const hit = 'node(around:20,3,3);out;';
|
||||
const hitKey = `data=${encodeURIComponent(hit)}`;
|
||||
t.after(() => {
|
||||
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
|
||||
else process.env.OVERPASS_UPSTREAMS = prior;
|
||||
_overpassCache.delete(hitKey);
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
_overpassCache.set(hitKey, {
|
||||
status: 200,
|
||||
body: '{"elements":[]}',
|
||||
contentType: 'application/json',
|
||||
endpoint: 'https://overpass.example/api/interpreter',
|
||||
cachedAt: clock.now,
|
||||
});
|
||||
const route = handler();
|
||||
// The proxy connects over loopback; a rotating forwarded client address
|
||||
// must not mint fresh buckets, so all 300 land in one socket-keyed bucket.
|
||||
for (const proxyPeer of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) {
|
||||
const proxied = (i) => ({
|
||||
peer: proxyPeer,
|
||||
headers: { 'x-forwarded-for': `203.0.113.${i % 250}` },
|
||||
body: form(hit),
|
||||
});
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal((await call(route, proxied(i))).statusCode, 200, proxyPeer);
|
||||
}
|
||||
const refused = await call(route, { ...proxied(300), forbidRead: true });
|
||||
assert.equal(refused.statusCode, 429, `${proxyPeer} proxy bucket is full`);
|
||||
assert.equal(refused.reads, 0);
|
||||
for (const peer of ['127.0.0.1', '::1']) {
|
||||
const owner = await call(route, { peer, body: form(hit) });
|
||||
assert.equal(owner.statusCode, 200, `direct ${peer} is still admitted`);
|
||||
assert.equal(owner.headers['x-overpass-cache'], 'HIT');
|
||||
}
|
||||
clock.now += 60_001;
|
||||
assert.equal((await call(route, proxied(0))).statusCode, 200);
|
||||
}
|
||||
});
|
||||
|
||||
test('proxied loopback traffic draws on the shared remote global window', async (t) => {
|
||||
const clock = freshClock(t);
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
const route = handler();
|
||||
for (const peer of ['10.0.0.1', '10.0.0.2']) {
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal((await call(route, { peer })).statusCode, 400, peer);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal(
|
||||
(await call(route, { headers: { via: '1.1 proxy' } })).statusCode,
|
||||
400,
|
||||
);
|
||||
}
|
||||
const starved = await call(route, { peer: '10.0.0.3', forbidRead: true });
|
||||
assert.equal(starved.statusCode, 429, 'proxied requests counted globally');
|
||||
assert.equal((await call(route, {})).statusCode, 400, 'owner unaffected');
|
||||
});
|
||||
|
||||
test("a local proxy has its own upstream bucket, so its misses cannot spend the owner's", async (t) => {
|
||||
freshClock(t);
|
||||
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
|
||||
t.after(() => {
|
||||
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
|
||||
else process.env.OVERPASS_UPSTREAMS = prior;
|
||||
});
|
||||
// Not data: nothing is cached, so every request below is a miss.
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('upstream unavailable', { status: 503 }),
|
||||
);
|
||||
const route = handler();
|
||||
const miss = form('node(around:20,4,4);out;');
|
||||
// The upstream limiter's own refusal is the only miss without this header.
|
||||
const passedLimiter = (response) =>
|
||||
response.headers['x-overpass-cache'] !== undefined;
|
||||
const proxied = { peer: '127.0.0.1', headers: { via: '1.1 proxy' } };
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const response = await call(route, { ...proxied, body: miss });
|
||||
assert.equal(passedLimiter(response), true, `proxied miss ${i}`);
|
||||
}
|
||||
const spent = await call(route, { ...proxied, body: miss });
|
||||
assert.equal(spent.statusCode, 429);
|
||||
assert.equal(passedLimiter(spent), false, 'the proxy bucket is spent');
|
||||
const owner = await call(route, { peer: '127.0.0.1', body: miss });
|
||||
assert.equal(passedLimiter(owner), true, 'the owner keeps its own bucket');
|
||||
});
|
||||
@@ -7,13 +7,9 @@ export {
|
||||
publicRadioHttpsUrl,
|
||||
} from './radio/stations.js';
|
||||
export function radioBrowserProxy() {
|
||||
let middleware;
|
||||
const initialize = () =>
|
||||
(middleware ||= createRadioProxyMiddleware({
|
||||
priorityCountries: process.env.RADIO_PRIORITY_COUNTRIES || '',
|
||||
}));
|
||||
const middleware = createRadioProxyMiddleware();
|
||||
const install = (server) => {
|
||||
server.middlewares.use('/api/radio', initialize());
|
||||
server.middlewares.use('/api/radio', middleware);
|
||||
};
|
||||
return {
|
||||
name: 'radio-browser-proxy',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { normalizeRadioCountryInput } from '../../../src/data/radioCountry.js';
|
||||
import { lookup as lookupDns } from 'node:dns/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
@@ -28,34 +27,6 @@ import {
|
||||
RADIO_FALLBACK_MIRRORS,
|
||||
RADIO_REJECTED_STATION_COUNT_MAX,
|
||||
} from './constants.js';
|
||||
const PRIORITY_COUNTRY_LIMIT = 8;
|
||||
const COUNTRY_QUERY_LIMIT = 200;
|
||||
const COUNTRY_RESERVED_STATIONS = 40;
|
||||
const REFRESH_RETRY_MS = 60_000;
|
||||
const MAX_MIRROR_ATTEMPTS = 3;
|
||||
|
||||
/** Validate an operator-owned list; malformed configuration must not silently widen queries. */
|
||||
export function parseRadioPriorityCountries(value = '') {
|
||||
const entries = Array.isArray(value) ? value : String(value).split(',');
|
||||
const countries = [];
|
||||
for (const entry of entries) {
|
||||
const input = String(entry).trim().toUpperCase();
|
||||
if (!input) continue;
|
||||
const normalized = normalizeRadioCountryInput(input);
|
||||
if (!/^[A-Z]{2}$/.test(input) || !normalized.valid)
|
||||
throw new TypeError(
|
||||
'RADIO_PRIORITY_COUNTRIES requires ISO alpha-2 country codes',
|
||||
);
|
||||
const code = normalized.code;
|
||||
if (!countries.includes(code)) countries.push(code);
|
||||
}
|
||||
if (countries.length > PRIORITY_COUNTRY_LIMIT)
|
||||
throw new RangeError(
|
||||
'RADIO_PRIORITY_COUNTRIES supports at most eight countries',
|
||||
);
|
||||
return countries;
|
||||
}
|
||||
|
||||
async function mapRadioConcurrent(values, concurrency, mapper) {
|
||||
const results = new Array(values.length);
|
||||
let cursor = 0;
|
||||
@@ -78,9 +49,7 @@ export function createRadioProxyMiddleware({
|
||||
fetchImpl = null,
|
||||
lookupImpl = lookupDns,
|
||||
now = Date.now,
|
||||
priorityCountries = [],
|
||||
} = {}) {
|
||||
const countries = parseRadioPriorityCountries(priorityCountries);
|
||||
let mirrorCache = { origins: [...RADIO_FALLBACK_MIRRORS], cachedAt: 0 };
|
||||
let mirrorPromise = null;
|
||||
let catalogCache = null;
|
||||
@@ -92,9 +61,6 @@ export function createRadioProxyMiddleware({
|
||||
const catalogInstance = randomUUID();
|
||||
let servedStationIds = new Set();
|
||||
let refreshPromise = null;
|
||||
let retryAfter = 0;
|
||||
let partialCatalog = null;
|
||||
let refreshError = null;
|
||||
|
||||
async function fetchJson(url, maxBytes = RADIO_RESPONSE_MAX_BYTES) {
|
||||
const destination = radioProxyDestination(url);
|
||||
@@ -173,7 +139,7 @@ export function createRadioProxyMiddleware({
|
||||
|
||||
async function fetchPath(pathname) {
|
||||
let lastError = null;
|
||||
for (const origin of (await mirrors()).slice(0, MAX_MIRROR_ATTEMPTS)) {
|
||||
for (const origin of await mirrors()) {
|
||||
try {
|
||||
return await fetchJson(`${origin}${pathname}`);
|
||||
} catch (error) {
|
||||
@@ -194,27 +160,20 @@ export function createRadioProxyMiddleware({
|
||||
'aviation',
|
||||
'marine',
|
||||
'traffic',
|
||||
].map((tag) => ({ tag }));
|
||||
const baseQueryCount = queries.length;
|
||||
queries.push(...countries.map((country) => ({ country })));
|
||||
];
|
||||
const outcomes = await mapRadioConcurrent(
|
||||
queries,
|
||||
3,
|
||||
async ({ tag, country }, index) => {
|
||||
async (tag, index) => {
|
||||
const params = new URLSearchParams({
|
||||
has_geo_info: 'true',
|
||||
is_https: 'true',
|
||||
hidebroken: 'true',
|
||||
order: 'clickcount',
|
||||
reverse: 'true',
|
||||
limit: country
|
||||
? String(COUNTRY_QUERY_LIMIT)
|
||||
: index === 0
|
||||
? '1800'
|
||||
: '220',
|
||||
limit: index === 0 ? '1800' : '220',
|
||||
});
|
||||
if (tag) params.set('tag', tag);
|
||||
if (country) params.set('countrycode', country);
|
||||
try {
|
||||
const rows = await fetchPath(`/json/stations/search?${params}`);
|
||||
if (!Array.isArray(rows))
|
||||
@@ -236,12 +195,7 @@ export function createRadioProxyMiddleware({
|
||||
const rejectedRows = rows.length - structuralRows.length;
|
||||
const stations = structuralRows
|
||||
.map(normalizeRadioBrowserStation)
|
||||
.filter(
|
||||
(station) =>
|
||||
station && (!country || station.countryCode === country),
|
||||
);
|
||||
if (country && rows.length > COUNTRY_QUERY_LIMIT)
|
||||
throw new Error('Radio Browser exceeded the country query limit');
|
||||
.filter(Boolean);
|
||||
const requestedTag = normalizeRadioTag(tag);
|
||||
const requestedTagCovered =
|
||||
!requestedTag ||
|
||||
@@ -253,12 +207,10 @@ export function createRadioProxyMiddleware({
|
||||
),
|
||||
);
|
||||
return {
|
||||
// A valid country response can contain no playable stations. Country
|
||||
// coverage is advisory; only broad/specialist queries gate health.
|
||||
// Specialist responses need an accepted station with matching tags.
|
||||
succeeded: country
|
||||
? rows.length === 0 || structuralRows.length > 0
|
||||
: stations.length > 0 && requestedTagCovered,
|
||||
// Query coverage is based on accepted rows, not merely a payload that
|
||||
// happens to match the upstream schema. Specialist responses must
|
||||
// also contain an accepted station tagged for the requested category.
|
||||
succeeded: stations.length > 0 && requestedTagCovered,
|
||||
stations,
|
||||
rejectedRows,
|
||||
};
|
||||
@@ -281,24 +233,9 @@ export function createRadioProxyMiddleware({
|
||||
seen.add(station.id);
|
||||
selected.push(station);
|
||||
};
|
||||
// Reserve country slots first; low-click regional stations cannot lose all
|
||||
// representation to the global charts. Keep room for all eight specialist
|
||||
// seeds (8 * 45) and global fill even at the eight-country limit (8 * 40).
|
||||
for (const rows of resultSets.slice(baseQueryCount)) {
|
||||
const ranked = [...rows].sort(
|
||||
(a, b) => b.clickCount - a.clickCount || a.id.localeCompare(b.id),
|
||||
);
|
||||
let added = 0;
|
||||
for (const station of ranked) {
|
||||
if (seen.has(station.id)) continue;
|
||||
take(station);
|
||||
if (++added >= COUNTRY_RESERVED_STATIONS) break;
|
||||
}
|
||||
}
|
||||
// Seed specialist station-tag queries before popularity fill so operational
|
||||
// categories remain represented even when global click charts skew musical.
|
||||
for (const rows of resultSets.slice(1, baseQueryCount))
|
||||
rows.slice(0, 45).forEach(take);
|
||||
for (const rows of resultSets.slice(1)) rows.slice(0, 45).forEach(take);
|
||||
resultSets
|
||||
.flat()
|
||||
.sort(
|
||||
@@ -306,9 +243,9 @@ export function createRadioProxyMiddleware({
|
||||
)
|
||||
.forEach(take);
|
||||
const timestamp = now();
|
||||
const successfulQueries = outcomes
|
||||
.slice(0, baseQueryCount)
|
||||
.filter((outcome) => outcome.succeeded).length;
|
||||
const successfulQueries = outcomes.filter(
|
||||
(outcome) => outcome.succeeded,
|
||||
).length;
|
||||
const broadQueryHealthy =
|
||||
outcomes[0].succeeded && outcomes[0].stations.length > 0;
|
||||
const healthReasons = [];
|
||||
@@ -324,18 +261,7 @@ export function createRadioProxyMiddleware({
|
||||
);
|
||||
const coverage = {
|
||||
successfulQueries,
|
||||
totalQueries: baseQueryCount,
|
||||
...(countries.length
|
||||
? {
|
||||
priorityCountries: countries.map((country, index) => ({
|
||||
countryCode: country,
|
||||
succeeded: outcomes[baseQueryCount + index].succeeded,
|
||||
stationCount: selected.filter(
|
||||
(station) => station.countryCode === country,
|
||||
).length,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
totalQueries: queries.length,
|
||||
stationCount: selected.length,
|
||||
healthyStationMinimum: RADIO_CATALOG_HEALTHY_MIN_STATIONS,
|
||||
};
|
||||
@@ -389,24 +315,7 @@ export function createRadioProxyMiddleware({
|
||||
return { ...catalogCache, stale: false };
|
||||
}
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
if (now() < retryAfter) {
|
||||
if (refreshError) throw refreshError;
|
||||
return partialCatalog;
|
||||
}
|
||||
try {
|
||||
const catalog = await refreshCatalog();
|
||||
refreshError = null;
|
||||
partialCatalog = catalog.degraded ? catalog : null;
|
||||
retryAfter = catalog.degraded ? now() + REFRESH_RETRY_MS : 0;
|
||||
return catalog;
|
||||
} catch (error) {
|
||||
refreshError = error;
|
||||
partialCatalog = null;
|
||||
retryAfter = now() + REFRESH_RETRY_MS;
|
||||
throw error;
|
||||
}
|
||||
})().finally(() => {
|
||||
refreshPromise = refreshCatalog().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolveCatalogSources } from './sourceComposition.js';
|
||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||
import { worldClock } from '../time/worldClock.js';
|
||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||
import { createWindLayer } from '../layers/wind/index.js';
|
||||
@@ -29,7 +30,6 @@ import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
||||
import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
||||
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
|
||||
import { createApplicationCables } from './layers/submarineCables.js';
|
||||
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
|
||||
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
||||
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
||||
import { localGeoJsonServices } from './localGeojsonServices.js';
|
||||
@@ -73,7 +73,7 @@ export function createApplicationCatalog({
|
||||
const sourceComposition = resolveCatalogSources(sources);
|
||||
sources = sourceComposition.sources;
|
||||
const militaryRegistry = createMilitaryRegistry();
|
||||
const weatherClock = createWeatherClock();
|
||||
const weatherClock = createWeatherClock({ world: worldClock });
|
||||
const dispose = () => {
|
||||
signal.removeEventListener('abort', dispose);
|
||||
militaryRegistry.dispose();
|
||||
@@ -170,7 +170,6 @@ export function createApplicationCatalog({
|
||||
clock: weatherClock,
|
||||
}),
|
||||
createCyclonesLayer({ feed: sources.cyclones }),
|
||||
...createOsmOverlayLayers(),
|
||||
...createInfrastructureLayers(localGeoJsonServices),
|
||||
createApplicationCables({ source: sources.cables }),
|
||||
createApplicationFirms({
|
||||
|
||||
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
signal: b.signal,
|
||||
surface: fixtureSurface(b.signal),
|
||||
});
|
||||
assert.equal(first.layers.length, 35);
|
||||
assert.equal(first.layers.length, 33);
|
||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'local-adsb'),
|
||||
@@ -155,7 +155,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
};
|
||||
const catalog = createApplicationCatalog(options);
|
||||
assert.equal(catalog.layers.length, 35);
|
||||
assert.equal(catalog.layers.length, 33);
|
||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||
assert.equal(
|
||||
catalogControlServices(catalog).streetLevelLayer,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
|
||||
export function createApplicationGnssIntegrity(options) {
|
||||
return createGnssIntegrityLayer(options);
|
||||
return createGnssIntegrityLayer({
|
||||
worldNow: worldTimeFor('gnss-integrity'),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
|
||||
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
|
||||
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
|
||||
import { governorRequestRender } from '../../renderGovernor.js';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
const imageryTime = worldTimeFor('recent-imagery');
|
||||
|
||||
/** Construct imagery presentation over one explicitly supplied acquisition source. */
|
||||
export function createApplicationRecentImagery({ source }) {
|
||||
@@ -15,5 +18,6 @@ export function createApplicationRecentImagery({ source }) {
|
||||
thumbnails: createThumbnailLoader({
|
||||
requestThumbnail: (...args) => source.getThumbnail(...args),
|
||||
}),
|
||||
now: () => new Date(imageryTime()),
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
governorRequestRender,
|
||||
} from '../renderGovernor.js';
|
||||
import { describeError } from './errors.js';
|
||||
import { worldClock, worldTimeFor } from '../time/worldClock.js';
|
||||
import { followWorldTime } from '../time/viewerClock.js';
|
||||
import { onLocaleChange, t } from '../i18n/index.js';
|
||||
|
||||
/** Construct the application globe using the caller's local configuration. */
|
||||
@@ -58,6 +60,12 @@ export async function createApplicationScene({
|
||||
if (!viewer.isDestroyed()) viewer.destroy();
|
||||
});
|
||||
defer(installTrackpadPinchZoom(viewer));
|
||||
defer(
|
||||
followWorldTime(viewer, worldTimeFor('sky'), {
|
||||
subscribe: worldClock.subscribe,
|
||||
requestRender: () => governorRequestRender('world-time'),
|
||||
}),
|
||||
);
|
||||
registerDataCredits(viewer, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
||||
|
||||
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
|
||||
|
||||
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
assert.equal(actual.layers.length, 35);
|
||||
assert.equal(actual.layers.length, 33);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
|
||||
+14
-1
@@ -1,5 +1,9 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { governorRequestRender } from './renderGovernor.js';
|
||||
import { worldClock, worldTimeFor } from './time/worldClock.js';
|
||||
|
||||
// The ring shows the sun and moon at the time the sky is drawn.
|
||||
const skyTime = worldTimeFor('sky');
|
||||
|
||||
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
|
||||
export const KEYHOLE_OUTER_RADIUS = 1.05;
|
||||
@@ -445,6 +449,12 @@ export class CelestialRing {
|
||||
if (typeof document !== 'undefined' && document.hidden) return;
|
||||
governorRequestRender('celestial-ephemeris');
|
||||
}, 60_000);
|
||||
// A jump in the world time moves the sun and moon at once.
|
||||
this._skyDate = new Date();
|
||||
this._skyTime = new Cesium.JulianDate();
|
||||
this._unsubscribeWorldTime = worldClock.subscribe(() => {
|
||||
this._ephemerisDirty = true;
|
||||
});
|
||||
this.setEnabled(this.enabled);
|
||||
}
|
||||
|
||||
@@ -801,7 +811,8 @@ export class CelestialRing {
|
||||
return;
|
||||
}
|
||||
|
||||
const time = Cesium.JulianDate.now();
|
||||
this._skyDate.setTime(skyTime());
|
||||
const time = Cesium.JulianDate.fromDate(this._skyDate, this._skyTime);
|
||||
if (!this._updateEphemeris(time)) return;
|
||||
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
|
||||
const camera = this.viewer.camera;
|
||||
@@ -890,6 +901,8 @@ export class CelestialRing {
|
||||
clearInterval(this._ephemerisTimer);
|
||||
this._ephemerisTimer = null;
|
||||
}
|
||||
this._unsubscribeWorldTime?.();
|
||||
this._unsubscribeWorldTime = null;
|
||||
if (this._removePostRender) this._removePostRender();
|
||||
this._removePostRender = null;
|
||||
if (this._shown()) {
|
||||
|
||||
@@ -229,13 +229,30 @@ const GUARDED_PROPS = new Set([
|
||||
|
||||
// Custom properties allowed inside a modelled offset. Each is a pinned-tray
|
||||
// stack height, proven non-negative below, so evaluating them at 0 is a
|
||||
// conservative floor rather than a guess.
|
||||
// conservative floor rather than a guess. The world timeline's lift raises
|
||||
// the dock, its trays and the credit together, so it moves no gap either.
|
||||
const VETTED_VARS = new Set([
|
||||
'--dock-pinned-stack-height',
|
||||
'--dock-lower-pinned-height',
|
||||
'--dock-location-pinned-height',
|
||||
'--dock-presets-pinned-height',
|
||||
'--world-timeline-lift',
|
||||
]);
|
||||
// Custom properties CSS sets to a fixed length besides their 0 default. The
|
||||
// clearance checks run at each length CSS gives them, not only at 0.
|
||||
const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']);
|
||||
const LIFTS_PX = [0, 50, 80];
|
||||
let liftPx = 0;
|
||||
function eachLift(check) {
|
||||
for (const lift of LIFTS_PX) {
|
||||
liftPx = lift;
|
||||
try {
|
||||
check(lift);
|
||||
} finally {
|
||||
liftPx = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Rules whose final compound targets a modelled element (pseudo-elements aside). */
|
||||
function ownBoxEntries() {
|
||||
@@ -315,6 +332,7 @@ function toPx(value, viewportHeight, where) {
|
||||
if (term.startsWith('var(')) {
|
||||
const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim();
|
||||
assert.ok(VETTED_VARS.has(name), `unvetted custom property ${name} in ${where}: "${value}"`);
|
||||
if (name === '--world-timeline-lift') total += liftPx;
|
||||
continue; // proven non-negative; 0 is the conservative floor
|
||||
}
|
||||
assert.match(term, /^-?[\d.]+(px|rem|vh)$/, `unmodelled length term "${term}" in ${where}: "${value}"`);
|
||||
@@ -444,7 +462,8 @@ test('custom properties inside modelled offsets are provably non-negative', () =
|
||||
assert.ok(declared.length >= VETTED_VARS.size, 'every vetted custom property needs a CSS default');
|
||||
for (const decl of declared) {
|
||||
assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`);
|
||||
assert.match(decl.value, /^(0|0px)$/, `${decl.prop} default is not a vetted shape: ${decl.value}`);
|
||||
const shape = FIXED_LENGTH_VARS.has(decl.prop) ? /^(0|\d+px)$/ : /^(0|0px)$/;
|
||||
assert.match(decl.value, shape, `${decl.prop} default is not a vetted shape: ${decl.value}`);
|
||||
}
|
||||
const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
|
||||
const start = layout.indexOf('_updateCommandDockTrayStack() {');
|
||||
@@ -517,16 +536,18 @@ test('every open dock tray clears the required credit at every modelled viewport
|
||||
// number: the dock is anchored at 2vh down to 721px and re-anchors to a flat
|
||||
// 8px at 720px, while the credit keeps its 2vh base throughout.
|
||||
const failures = [];
|
||||
for (const scenario of TRAY_SCENARIOS) {
|
||||
for (const width of WIDTHS.filter((w) => w <= 900)) {
|
||||
for (const height of HEIGHTS) {
|
||||
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
|
||||
if (clearance < MIN_CLEARANCE_PX) {
|
||||
failures.push(`${scenario.name} @ ${width}x${height}: ${clearance.toFixed(1)}px`);
|
||||
eachLift((lift) => {
|
||||
for (const scenario of TRAY_SCENARIOS) {
|
||||
for (const width of WIDTHS.filter((w) => w <= 900)) {
|
||||
for (const height of HEIGHTS) {
|
||||
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
|
||||
if (clearance < MIN_CLEARANCE_PX) {
|
||||
failures.push(`${scenario.name} @ ${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
assert.deepEqual(failures, [], `a dock tray re-enters the credit band:\n ${failures.join('\n ')}\n`);
|
||||
});
|
||||
|
||||
@@ -566,21 +587,24 @@ test('the full-width context rail clears the required credit at every modelled v
|
||||
'none',
|
||||
`at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`,
|
||||
);
|
||||
for (const height of HEIGHTS) {
|
||||
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
|
||||
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
|
||||
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height}: ${clearance.toFixed(1)}px`);
|
||||
}
|
||||
eachLift((lift) => {
|
||||
for (const height of HEIGHTS) {
|
||||
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
|
||||
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
|
||||
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
|
||||
}
|
||||
});
|
||||
}
|
||||
assert.deepEqual(failures, [], `context rail re-enters the credit band at ${failures.join(', ')}`);
|
||||
});
|
||||
|
||||
test('the dock anchor changes at 720px — the 2vh cancellation is band-limited', () => {
|
||||
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, '2vh');
|
||||
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, '8px');
|
||||
const lift = 'var(--world-timeline-lift, 0px)';
|
||||
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, `calc(2vh + ${lift})`);
|
||||
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, `calc(8px + ${lift})`);
|
||||
assert.equal(
|
||||
resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value,
|
||||
'calc(2vh + 5rem)',
|
||||
`calc(2vh + 5rem + ${lift})`,
|
||||
'the credit keeps its 2vh base below 720px — that asymmetry is the whole hazard',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -150,20 +150,6 @@ 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:
|
||||
|
||||
@@ -17,7 +17,6 @@ export const LAYER_FEED_STATES = Object.freeze([
|
||||
'loading',
|
||||
'degraded',
|
||||
'stale',
|
||||
'partial',
|
||||
'fallback',
|
||||
'unavailable',
|
||||
]);
|
||||
@@ -28,7 +27,6 @@ export const LAYER_FEED_STATE_LABELS = Object.freeze({
|
||||
loading: 'LOADING',
|
||||
degraded: 'DEGRADED',
|
||||
stale: 'STALE',
|
||||
partial: 'PARTIAL',
|
||||
fallback: 'FALLBACK',
|
||||
unavailable: 'UNAVAILABLE',
|
||||
});
|
||||
@@ -50,9 +48,8 @@ export const FEED_STATE_SEVERITY = Object.freeze({
|
||||
degraded: 2,
|
||||
stale: 3,
|
||||
fallback: 4,
|
||||
partial: 5,
|
||||
nominal: 6,
|
||||
off: 7,
|
||||
nominal: 5,
|
||||
off: 6,
|
||||
});
|
||||
|
||||
export { layerFeedState } from './feedState.js';
|
||||
@@ -93,7 +90,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'|'partial'|'fallback'|'unavailable'|'off'|null}
|
||||
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'|'off'|null}
|
||||
*/
|
||||
export function worstFeedState(states) {
|
||||
let worst = null;
|
||||
@@ -208,8 +205,6 @@ 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.`;
|
||||
}
|
||||
|
||||
@@ -256,63 +251,3 @@ 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;
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
layerFeedState,
|
||||
layerSnapshot,
|
||||
layerSnapshots,
|
||||
provenanceTag,
|
||||
snapshotAgeLabel,
|
||||
snapshotAgeSec,
|
||||
viewStateLayerRow,
|
||||
@@ -91,15 +90,7 @@ 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',
|
||||
'partial',
|
||||
'fallback',
|
||||
'unavailable',
|
||||
],
|
||||
['nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable'],
|
||||
);
|
||||
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
|
||||
for (const state of LAYER_FEED_STATES) {
|
||||
@@ -116,33 +107,6 @@ 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(
|
||||
{
|
||||
@@ -294,18 +258,3 @@ 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'],
|
||||
});
|
||||
});
|
||||
@@ -666,8 +666,6 @@ 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',
|
||||
|
||||
@@ -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, 34);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 34);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 32);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 32);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
||||
@@ -242,18 +242,20 @@ 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(), '8');
|
||||
assert.equal(nextLayerStateToken(), '6');
|
||||
assert.equal(
|
||||
nextLayerStateToken({
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
alpha: '8',
|
||||
bravo: '9',
|
||||
alpha: '6',
|
||||
bravo: '7',
|
||||
}),
|
||||
'00',
|
||||
'8',
|
||||
);
|
||||
const digitsExhausted = {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
...Object.fromEntries([...'89'].map((digit) => [`prior-${digit}`, digit])),
|
||||
...Object.fromEntries(
|
||||
[...'6789'].map((digit) => [`prior-${digit}`, digit]),
|
||||
),
|
||||
};
|
||||
assert.equal(nextLayerStateToken(digitsExhausted), '00');
|
||||
assert.equal(
|
||||
@@ -305,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '8',
|
||||
next: '9',
|
||||
future: '6',
|
||||
next: '7',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -316,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
}),
|
||||
/next free token 8/,
|
||||
/next free token 6/,
|
||||
);
|
||||
const beforeLastDigit = { ...digitsExhausted };
|
||||
delete beforeLastDigit['prior-9'];
|
||||
@@ -339,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8', competing: '8' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6', competing: '6' },
|
||||
),
|
||||
/next free token 9/,
|
||||
/next free token 7/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
|
||||
|
||||
@@ -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, 34);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 32);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{
|
||||
@@ -137,8 +137,6 @@ 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(
|
||||
@@ -164,9 +162,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
assert.equal(
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '8']])
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '6']])
|
||||
.future,
|
||||
'8',
|
||||
'6',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -184,14 +182,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', '8']] },
|
||||
{ rows: [...reservationRows, ['retired-layer', '6']] },
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '9');
|
||||
assert.equal(nextLayerStateToken(published), '7');
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
newcomer: '9',
|
||||
newcomer: '7',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -199,15 +197,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
competing: '8',
|
||||
competing: '6',
|
||||
}),
|
||||
/next free token 9/,
|
||||
/next free token 7/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
newcomer: '8',
|
||||
newcomer: '6',
|
||||
}),
|
||||
/changed or removed: retired-layer/,
|
||||
);
|
||||
@@ -215,8 +213,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
);
|
||||
});
|
||||
|
||||
test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '8']] }, (cwd) => {
|
||||
test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '6']] }, (cwd) => {
|
||||
mkdirSync(path.join(cwd, 'scripts'));
|
||||
const fixtureChecker = path.join(
|
||||
cwd,
|
||||
@@ -237,8 +235,8 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-b', '8']],
|
||||
[{ id: 'pr-b', token: '8' }],
|
||||
[...reservationRows, ['pr-b', '6']],
|
||||
[{ id: 'pr-b', token: '6' }],
|
||||
);
|
||||
const beforeRebase = runCheck();
|
||||
assert.equal(beforeRebase.status, 1);
|
||||
@@ -246,10 +244,10 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '8'], ['pr-b', '8']],
|
||||
[...reservationRows, ['pr-a', '6'], ['pr-b', '6']],
|
||||
[
|
||||
{ id: 'pr-a', token: '8' },
|
||||
{ id: 'pr-b', token: '8' },
|
||||
{ id: 'pr-a', token: '6' },
|
||||
{ id: 'pr-b', token: '6' },
|
||||
],
|
||||
);
|
||||
const staleRebase = runCheck();
|
||||
@@ -257,16 +255,16 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
|
||||
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
|
||||
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '9');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '8']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '8' }]);
|
||||
assert.equal(nextLayerStateToken(published), '7');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '6']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '6' }]);
|
||||
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: 9/);
|
||||
assert.match(helper.stdout, /pr-b: 7/);
|
||||
assert.deepEqual(
|
||||
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
||||
validBaselineRows,
|
||||
@@ -274,20 +272,20 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
|
||||
);
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '8'], ['pr-b', '9']],
|
||||
[...reservationRows, ['pr-a', '6'], ['pr-b', '7']],
|
||||
[
|
||||
{ id: 'pr-a', token: '8' },
|
||||
{ id: 'pr-b', token: '9' },
|
||||
{ id: 'pr-a', token: '6' },
|
||||
{ id: 'pr-b', token: '7' },
|
||||
],
|
||||
);
|
||||
const corrected = runCheck();
|
||||
assert.equal(corrected.status, 0, corrected.stderr);
|
||||
assert.match(corrected.stdout, /35 published, 1 new/);
|
||||
assert.match(corrected.stdout, /33 published, 1 new/);
|
||||
});
|
||||
});
|
||||
|
||||
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
||||
const digitRows = [...'89'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const digitRows = [...'6789'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const allDigits = parseLayerStateTokenReservations([
|
||||
...reservationRows,
|
||||
...digitRows,
|
||||
@@ -339,7 +337,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 = [...'89'].map((digit) => [
|
||||
const priorDigits = [...'6789'].map((digit) => [
|
||||
`qa-prior-digit-${digit}`,
|
||||
digit,
|
||||
]);
|
||||
@@ -376,7 +374,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, 79);
|
||||
assert.equal(params.get('l').length, 75);
|
||||
const restored = codec.decodeLayerStateParams(params);
|
||||
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
|
||||
assert.deepEqual(restored?.options, state.options);
|
||||
@@ -394,7 +392,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, 35);
|
||||
assert.equal(Object.keys(published).length, 33);
|
||||
assert.equal(published['retired-layer'], '00');
|
||||
assert.throws(
|
||||
() =>
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
["rocket-launches", "x"],
|
||||
["s2-ghostmaps", "5"],
|
||||
["satellites", "s"],
|
||||
["sea-marks", "6"],
|
||||
["ski-pistes", "7"],
|
||||
["street-level", "0"],
|
||||
["telegeography-submarine-cables", "u"],
|
||||
["traffic", "t"],
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
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));
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
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);
|
||||
});
|
||||
+75
-16
@@ -51,7 +51,6 @@ import {
|
||||
radioViewIsGlobal,
|
||||
stationMatchesRadioCategory,
|
||||
} from './radio.js';
|
||||
import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs';
|
||||
|
||||
const stations = [
|
||||
{ id: 'news', tags: ['News', 'Weather Radio', 'air traffic'] },
|
||||
@@ -884,33 +883,88 @@ 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',
|
||||
}),
|
||||
radioStationRow({
|
||||
homepage: null,
|
||||
tags: ['news'],
|
||||
languages: ['English'],
|
||||
state: 'Texas',
|
||||
country: 'United States',
|
||||
countryCode: 'US',
|
||||
metadataTrust: 'untrusted-community',
|
||||
codec: 'MP3',
|
||||
bitrate: 128,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
];
|
||||
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));
|
||||
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);
|
||||
assert.equal(radioLayer.cycleStation(1, {
|
||||
stationIds: stationRows.map(({ id }) => id),
|
||||
autoplay: false,
|
||||
fallback: true,
|
||||
}), true, 'a voice-style prepared cycle creates the stale-fallback precondition');
|
||||
playUrls.length = 0;
|
||||
}), true, 'non-playing cycle creates the stale-fallback precondition');
|
||||
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);
|
||||
@@ -921,7 +975,12 @@ 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 () => {
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
@@ -541,191 +541,6 @@ test('a mirror answering an invalid reason phrase rejects instead of crashing',
|
||||
await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError);
|
||||
});
|
||||
|
||||
test('priority-country configuration is bounded, normalized and validated before requests', async () => {
|
||||
const { parseRadioPriorityCountries } = await import('../../server/providers/radio/catalog.js');
|
||||
assert.deepEqual(parseRadioPriorityCountries(' in, PL,in, '), ['IN', 'PL']);
|
||||
assert.deepEqual(parseRadioPriorityCountries(''), []);
|
||||
assert.deepEqual(parseRadioPriorityCountries('uk,GB,UK'), ['GB']);
|
||||
assert.deepEqual(parseRadioPriorityCountries('US,CA,IN,PL,DE,FR,UK,GB,JP'), ['US', 'CA', 'IN', 'PL', 'DE', 'FR', 'GB', 'JP']);
|
||||
for (const value of ['ZZ', 'India', 'IN&limit=9999', 'US,CA,IN,PL,DE,FR,GB,JP,AU']) {
|
||||
assert.throws(() => createProxy({ priorityCountries: value }), /RADIO_PRIORITY_COUNTRIES/);
|
||||
}
|
||||
});
|
||||
|
||||
test('UK aliases query and report GB once and retain British stations', async () => {
|
||||
const countryQueries = [];
|
||||
const middleware = createProxy({
|
||||
priorityCountries: 'UK,GB',
|
||||
fetchImpl: async (url) => {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.pathname === '/json/servers')
|
||||
return responseJson([{ name: 'de1.api.radio-browser.info' }]);
|
||||
const country = parsed.searchParams.get('countrycode');
|
||||
if (country) {
|
||||
countryQueries.push(country);
|
||||
return responseJson([station({ country: 'United Kingdom', countrycode: 'GB' })]);
|
||||
}
|
||||
return responseJson(healthyRowsForQuery(url));
|
||||
},
|
||||
});
|
||||
const response = await invoke(middleware, '/stations');
|
||||
assert.equal(response.status, 200);
|
||||
const body = JSON.parse(response.body);
|
||||
assert.deepEqual(countryQueries, ['GB']);
|
||||
assert.deepEqual(body.coverage.priorityCountries, [
|
||||
{ countryCode: 'GB', succeeded: true, stationCount: 1 },
|
||||
]);
|
||||
assert.equal(body.stations.find((row) => row.id === UUID)?.countryCode, 'GB');
|
||||
});
|
||||
|
||||
test('low-click country stations survive a full global catalog, with bounded cached queries and deduplication', async () => {
|
||||
const calls = [];
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const middleware = createProxy({
|
||||
priorityCountries: 'IN,PL',
|
||||
fetchImpl: async (url) => {
|
||||
const parsed = new URL(url);
|
||||
calls.push(parsed);
|
||||
if (parsed.pathname === '/json/servers') return responseJson([{ name: 'de1.api.radio-browser.info' }]);
|
||||
if (parsed.pathname.startsWith('/json/url/')) return responseJson({ ok: true });
|
||||
peak = Math.max(peak, ++active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
active--;
|
||||
const country = parsed.searchParams.get('countrycode');
|
||||
if (country) {
|
||||
assert.equal(parsed.searchParams.get('limit'), '200');
|
||||
assert.equal(parsed.searchParams.get('is_https'), 'true');
|
||||
const rows = catalogRows(country === 'IN' ? '10000000' : '20000000', 'community', 60)
|
||||
.map((row) => ({ ...row, countrycode: country, clickcount: 0 }));
|
||||
return responseJson([rows[0], ...rows]);
|
||||
}
|
||||
return responseJson(catalogRows('30000000', queryTag(url) || 'news', 900));
|
||||
},
|
||||
});
|
||||
const responses = await Promise.all([invoke(middleware, '/stations'), invoke(middleware, '/stations')]);
|
||||
const body = JSON.parse(responses[0].body);
|
||||
assert.equal(body.degraded, false);
|
||||
assert.equal(body.stations.length, 750);
|
||||
assert.equal(new Set(body.stations.map((row) => row.id)).size, 750);
|
||||
for (const country of ['IN', 'PL'])
|
||||
assert.ok(body.stations.filter((row) => row.countryCode === country).length >= 40);
|
||||
assert.ok(body.stations.some((row) => row.countryCode === 'US'));
|
||||
assert.equal(calls.filter((url) => url.pathname.includes('/stations/')).length, 11);
|
||||
assert.ok(peak <= 3);
|
||||
const count = calls.length;
|
||||
await invoke(middleware, '/stations');
|
||||
assert.equal(calls.length, count, 'country queries share the 45-minute cache');
|
||||
const id = body.stations.find((row) => row.countryCode === 'IN').id;
|
||||
assert.equal((await invoke(middleware, `/click/${id}`, 'POST')).status, 204);
|
||||
});
|
||||
|
||||
test('country failures never block cold or warm global catalog admission, even after seven days', async () => {
|
||||
for (const coldFailure of [false, true]) {
|
||||
let clock = 4_000_000;
|
||||
let fail = coldFailure;
|
||||
let calls = 0;
|
||||
const middleware = createProxy({
|
||||
priorityCountries: ['IN'], now: () => clock,
|
||||
fetchImpl: async (url) => {
|
||||
calls++;
|
||||
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
|
||||
if (new URL(url).searchParams.has('countrycode')) {
|
||||
if (fail) throw new Error('offline');
|
||||
return responseJson([station({ countrycode: 'IN', country: 'India', tags: 'community' })]);
|
||||
}
|
||||
return responseJson(healthyRowsForQuery(url));
|
||||
},
|
||||
});
|
||||
let generation = 0;
|
||||
for (const elapsed of [0, 46 * 60 * 1000, 8 * 24 * 60 * 60 * 1000]) {
|
||||
clock += elapsed;
|
||||
if (elapsed) fail = true;
|
||||
const response = await invoke(middleware, '/stations');
|
||||
const body = JSON.parse(response.body);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.stale, false);
|
||||
assert.equal(body.degraded, false);
|
||||
assert.ok(body.acceptedGeneration > generation);
|
||||
generation = body.acceptedGeneration;
|
||||
assert.equal(body.coverage.priorityCountries[0].succeeded, !fail);
|
||||
assert.equal(body.coverage.priorityCountries[0].stationCount, fail ? 0 : 1);
|
||||
const count = calls;
|
||||
clock += 60_001;
|
||||
assert.deepEqual(JSON.parse((await invoke(middleware, '/stations')).body), body);
|
||||
assert.equal(calls, count, 'country failure does not shorten the healthy cache');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('valid countries with no eligible stations are accepted and cached; malformed country responses stay isolated', async () => {
|
||||
for (const mode of ['wrong', 'oversized', 'empty', 'ineligible', 'malformed']) {
|
||||
let clock = 4_000_000;
|
||||
let calls = 0;
|
||||
const middleware = createProxy({
|
||||
priorityCountries: ['IN'], now: () => clock,
|
||||
fetchImpl: async (url) => {
|
||||
calls++;
|
||||
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
|
||||
if (new URL(url).searchParams.has('countrycode')) {
|
||||
const rows = {
|
||||
empty: [], wrong: [station()], malformed: [{ name: 'missing fields' }],
|
||||
oversized: catalogRows('10000000', 'community', 201).map((row) => ({ ...row, countrycode: 'IN' })),
|
||||
ineligible: [{ hls: 1 }, { codec: 'OGG' }, { codec: 'OPUS' }, { url_resolved: 'https://127.0.0.1/live.mp3' }]
|
||||
.map((overrides) => station({ countrycode: 'IN', ...overrides })),
|
||||
};
|
||||
return responseJson(rows[mode]);
|
||||
}
|
||||
return responseJson(healthyRowsForQuery(url));
|
||||
},
|
||||
});
|
||||
const response = await invoke(middleware, '/stations');
|
||||
const body = JSON.parse(response.body);
|
||||
assert.equal(response.status, 200, mode);
|
||||
assert.equal(body.degraded, false, mode);
|
||||
assert.ok(body.acceptedGeneration > 0, mode);
|
||||
assert.equal(body.coverage.priorityCountries[0].stationCount, 0, mode);
|
||||
assert.equal(body.coverage.priorityCountries[0].succeeded, !['oversized', 'malformed'].includes(mode), mode);
|
||||
const count = calls;
|
||||
clock += 44 * 60 * 1000;
|
||||
assert.deepEqual(JSON.parse((await invoke(middleware, '/stations')).body), body);
|
||||
assert.equal(calls, count, `${mode} country response preserves the 45-minute cache`);
|
||||
}
|
||||
});
|
||||
|
||||
for (const priorityCountries of ['', 'IN']) {
|
||||
test(`cold failures coalesce, bound mirrors and back off with priority countries ${priorityCountries || 'unset'}`, async () => {
|
||||
let clock = Date.UTC(2026, 0, 1);
|
||||
let calls = 0;
|
||||
let offline = true;
|
||||
const middleware = createProxy({
|
||||
priorityCountries, now: () => clock,
|
||||
fetchImpl: async (url) => {
|
||||
calls++;
|
||||
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
|
||||
if (offline) throw new Error('offline');
|
||||
if (new URL(url).searchParams.has('countrycode')) return responseJson([]);
|
||||
return responseJson(healthyRowsForQuery(url));
|
||||
},
|
||||
});
|
||||
const responses = await Promise.all([invoke(middleware, '/stations'), invoke(middleware, '/stations')]);
|
||||
assert.ok(responses.every((response) => response.status === 503));
|
||||
const initial = calls;
|
||||
assert.equal(initial, 1 + (priorityCountries ? 10 : 9) * 3, 'one discovery and at most three mirrors per query');
|
||||
offline = false;
|
||||
clock += 59_999;
|
||||
assert.equal((await invoke(middleware, '/stations')).status, 503);
|
||||
assert.equal(calls, initial);
|
||||
clock += 2;
|
||||
const recovered = await invoke(middleware, '/stations');
|
||||
assert.equal(recovered.status, 200);
|
||||
assert.equal(JSON.parse(recovered.body).degraded, false);
|
||||
assert.ok(calls > initial);
|
||||
const afterRecovery = calls;
|
||||
await invoke(middleware, '/stations');
|
||||
assert.equal(calls, afterRecovery);
|
||||
});
|
||||
}
|
||||
|
||||
test('case expansion is capped after tag normalization in the portable and server station path', () => {
|
||||
const row = normalizeRadioBrowserStation(station({tags: 'İ'.repeat(80) + ', NEWS__RADIO ,news-radio'}));
|
||||
@@ -784,29 +599,3 @@ test('server structural rejections reach the client diagnostic without discardin
|
||||
radioLayer.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('country coverage retains valid rows and upstream rejection diagnostics in a cached healthy catalog', async () => {
|
||||
let calls = 0;
|
||||
const middleware = createProxy({
|
||||
priorityCountries: 'IN',
|
||||
fetchImpl: async (url) => {
|
||||
calls++;
|
||||
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
|
||||
if (new URL(url).searchParams.has('countrycode')) return responseJson([
|
||||
station({ stationuuid: '60000000-0000-4000-8000-000000000001', countrycode: 'IN' }),
|
||||
{ name: 'missing required fields' },
|
||||
]);
|
||||
return responseJson(healthyRowsForQuery(url));
|
||||
},
|
||||
});
|
||||
const response = await invoke(middleware, '/stations');
|
||||
const body = JSON.parse(response.body);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.degraded, false);
|
||||
assert.equal(body.rejectedStationCount, 1);
|
||||
assert.deepEqual(body.coverage.priorityCountries, [{ countryCode: 'IN', succeeded: true, stationCount: 1 }]);
|
||||
const count = calls;
|
||||
assert.deepEqual(JSON.parse((await invoke(middleware, '/stations')).body), body);
|
||||
assert.equal(calls, count);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { gstime } from 'satellite.js';
|
||||
import { worldClock } from '../time/worldClock.js';
|
||||
import { createModel } from '../layers/launches/model.js';
|
||||
import rocketLaunchesLayer, {
|
||||
_setRocketMissionOverlayHostForTest,
|
||||
@@ -1240,7 +1241,11 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
||||
rocketLaunchesLayer.init(viewer);
|
||||
initialized = true;
|
||||
await rocketLaunchesLayer.enable();
|
||||
assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show');
|
||||
await rocketLaunchesLayer.update();
|
||||
const at = Date.parse(launchTime);
|
||||
assert.deepEqual(worldClock.coverage(), { start: at, end: at, marks: [at] });
|
||||
assert.equal(worldClock.isSupported(), true, 'loaded launches bring up the timeline');
|
||||
|
||||
const entities = dataSources[0].entities.values;
|
||||
assert.ok(entities.length >= 7, 'runtime guard requires the populated mission build path');
|
||||
@@ -1379,6 +1384,7 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
||||
assert.equal(deselectedPublication[2][0].protected, false);
|
||||
|
||||
await rocketLaunchesLayer.disable();
|
||||
assert.equal(worldClock.coverage(), null, 'off, launches show no time');
|
||||
assert.deepEqual(hostCalls.slice(-4), [
|
||||
['clear', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID],
|
||||
['visible', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false],
|
||||
@@ -1500,3 +1506,11 @@ test('missing payload details stay unknown without inventing mass or classificat
|
||||
const [launch] = normalizeRocketLaunches([{ id: 'missing', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z' }], NOW);
|
||||
assert.deepEqual(launch.payloads, []);
|
||||
});
|
||||
|
||||
test('away from live the feed keeps its own window of launches', (t) => {
|
||||
t.after(() => worldClock.goLive());
|
||||
worldClock.seek(Date.now() - 20 * 86_400_000);
|
||||
const net = new Date(Date.now() - 86_400_000).toISOString();
|
||||
const launches = normalizeRocketLaunches([{ id: 'recent', pad: { latitude: 1, longitude: 2 }, net }]);
|
||||
assert.deepEqual(launches.map((launch) => launch.id), ['recent']);
|
||||
});
|
||||
@@ -19,7 +19,6 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
resolveEllipsoidalGround,
|
||||
cachedEllipsoidalGround,
|
||||
cachedRealEllipsoidalGround,
|
||||
} from './terrainHeights.js';
|
||||
|
||||
const AUSTIN = { lat: 30.2672, lon: -97.7431 };
|
||||
@@ -363,47 +362,3 @@ 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']);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -190,8 +190,7 @@ 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;
|
||||
* the body's `partial` lists the real heights it has, null for the rest.
|
||||
* If any position has no real height, return 502 with no fabricated result.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {Array<[number, number]>} options.points
|
||||
@@ -270,19 +269,11 @@ 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,
|
||||
|
||||
@@ -268,31 +268,3 @@ 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'],
|
||||
);
|
||||
});
|
||||
@@ -1030,12 +1030,11 @@ 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; Sea Marks and Ski
|
||||
// Pistes extend the generated layer enums and alias hint.
|
||||
assert.equal(block.length, 31026, 'serialized tool schema length drifted');
|
||||
// The Utilities panel adds one set_panel_open target.
|
||||
assert.equal(block.length, 30919, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'5f5275cd6e320afef4fd224b235f3e0f191ec06599b257ffe1f60b703a255199',
|
||||
'dbf9a45649179e626b2e3d938c43903f573c50ffa279b3245b18244dbe9a9284',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(
|
||||
|
||||
+49
-156
@@ -23,6 +23,11 @@ import * as Cesium from 'cesium';
|
||||
import { greatCircleKm } from './geoDistance.js';
|
||||
import { forward as toMGRS } from 'mgrs';
|
||||
import { CITY_POIS } from './cityPois.js';
|
||||
import { worldTimeFor } from './time/worldClock.js';
|
||||
|
||||
// The HUD stamps its readouts with the world time; its sun is the sky's.
|
||||
const hudTime = worldTimeFor('hud');
|
||||
const skyTime = worldTimeFor('sky');
|
||||
import { composeLocalityTag } from './hudLocality.js';
|
||||
import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
|
||||
import {
|
||||
@@ -32,31 +37,17 @@ import {
|
||||
} from './data/geoid.js';
|
||||
import { getBasemapLabelContext } from './voice/gevActions.js';
|
||||
import {
|
||||
hasWord,
|
||||
hudSummaryHasEnglish,
|
||||
hudSummaryIsLatin,
|
||||
hudSummaryMatchesProvenance,
|
||||
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,
|
||||
getLocale,
|
||||
msg,
|
||||
onLocaleChange,
|
||||
t,
|
||||
} from './i18n/index.js';
|
||||
import { isRtlLocale } from './i18n/locale.js';
|
||||
import { formatNumber, msg, onLocaleChange, t } from './i18n/index.js';
|
||||
import { localizeLayerName } from './ui/layerPanel.js';
|
||||
import { isEmbeddedInline } from './app/embed.js';
|
||||
import { subscribeSelectedVessel } from './hudReadouts.js';
|
||||
@@ -122,58 +113,23 @@ 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 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.
|
||||
* 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.
|
||||
*/
|
||||
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)]
|
||||
: []),
|
||||
];
|
||||
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('/'),
|
||||
});
|
||||
}
|
||||
|
||||
/** The name shown for a _viewBand() id. */
|
||||
@@ -270,7 +226,7 @@ export class IntelHUD {
|
||||
this._placeLabels = null;
|
||||
this._lastSummarySignature = '';
|
||||
/** The reply painted for `_lastSummarySignature`, when it was accepted. */
|
||||
this._lastSummaryReply = null;
|
||||
this._lastSummaryText = 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..."
|
||||
@@ -299,17 +255,12 @@ export class IntelHUD {
|
||||
// the camera has moved to.
|
||||
this._onCameraMoveStart = () => {
|
||||
this._markSummaryDirty();
|
||||
const typing = Boolean(this._summaryTypingInterval);
|
||||
if (this._summaryRequest || typing) {
|
||||
if (this._summaryRequest || this._summaryTypingInterval) {
|
||||
// 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();
|
||||
@@ -349,12 +300,12 @@ export class IntelHUD {
|
||||
this._startTimers();
|
||||
}
|
||||
|
||||
/** Show the HUD's words in the new locale; drop AI text in the old one. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
_relocalize() {
|
||||
this._markSummaryDirty();
|
||||
this._lastSummarySignature = null;
|
||||
this._lastSummaryReply = null;
|
||||
this._summaryRequest?.abort();
|
||||
this._present({
|
||||
mode: MODE_LABELS[this._currentStyle]
|
||||
? t(MODE_LABELS[this._currentStyle])
|
||||
@@ -447,11 +398,11 @@ export class IntelHUD {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the current wall-clock time as a UTC Zulu string.
|
||||
* Format the HUD's world time as a UTC Zulu string.
|
||||
* @returns {string} Timestamp in `YYYY-MM-DD HH:MM:SSZ` format.
|
||||
*/
|
||||
_formatUTC() {
|
||||
const now = new Date();
|
||||
const now = new Date(hudTime());
|
||||
const y = now.getUTCFullYear();
|
||||
const mo = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getUTCDate()).padStart(2, '0');
|
||||
@@ -554,7 +505,7 @@ export class IntelHUD {
|
||||
readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
||||
|
||||
// Collection timestamp
|
||||
const now = new Date();
|
||||
const now = new Date(hudTime());
|
||||
const h = String(now.getUTCHours()).padStart(2, '0');
|
||||
const m = String(now.getUTCMinutes()).padStart(2, '0');
|
||||
const s = String(now.getUTCSeconds()).padStart(2, '0');
|
||||
@@ -661,7 +612,7 @@ export class IntelHUD {
|
||||
* @returns {number} Estimated sun elevation in degrees (negative = below horizon).
|
||||
*/
|
||||
_estimateSunElevation(lat, lon) {
|
||||
const now = new Date();
|
||||
const now = new Date(skyTime());
|
||||
// Approximate local solar time by shifting UTC hours by longitude offset
|
||||
const hours = now.getUTCHours() + now.getUTCMinutes() / 60 + lon / 15;
|
||||
const solarNoon = 12;
|
||||
@@ -817,8 +768,8 @@ export class IntelHUD {
|
||||
LOCALIZED_LOCALITY,
|
||||
);
|
||||
|
||||
const provenance = hudTelemetryProvenance(
|
||||
this._dataManager?.getAll?.() || [],
|
||||
const provenance = localizedProvenanceTag(
|
||||
hudTelemetryProvenance(this._dataManager?.getAll?.() || []),
|
||||
);
|
||||
// ALT, SUN and ONA are sensor codes, as in the corner readouts.
|
||||
const line = t(
|
||||
@@ -835,7 +786,7 @@ export class IntelHUD {
|
||||
zone: localTag,
|
||||
},
|
||||
);
|
||||
return withProvenance(line, provenance);
|
||||
return provenance ? `${line} | ${provenance}` : line;
|
||||
}
|
||||
|
||||
/** Stop a summary still typing out, leaving what it has typed so far. */
|
||||
@@ -908,28 +859,12 @@ 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.
|
||||
// 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),
|
||||
);
|
||||
if (this._lastSummaryText) this._setSummaryText(this._lastSummaryText);
|
||||
return;
|
||||
}
|
||||
if (this._summaryRequest) return;
|
||||
@@ -937,7 +872,7 @@ export class IntelHUD {
|
||||
if (force) this._setSummaryText(fallbackText, false);
|
||||
this._summaryDirty = false;
|
||||
this._lastSummarySignature = signature;
|
||||
this._lastSummaryReply = null;
|
||||
this._lastSummaryText = null;
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timeout = window.setTimeout(() => {
|
||||
@@ -960,23 +895,9 @@ export class IntelHUD {
|
||||
if (!response.ok || !data?.summary) {
|
||||
throw new Error(data?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
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,
|
||||
);
|
||||
if (hudSummaryMatchesProvenance(data.summary, context.feedProvenance))
|
||||
this._lastSummaryText = data.summary;
|
||||
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
|
||||
} catch (error) {
|
||||
// The 5 s timeout is a failure like any other; a move's abort (or
|
||||
// destroy's) is not.
|
||||
@@ -1034,46 +955,18 @@ 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 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,
|
||||
const enabledLayers =
|
||||
this._dataManager
|
||||
?.getAll?.()
|
||||
?.filter((layer) => layer.enabled)
|
||||
.map((layer) => layer.name) || [];
|
||||
return {
|
||||
placeLabels: labels.placeLabels,
|
||||
streetLabels: labels.streetLabels,
|
||||
nearbyPlaceLabels: labels.nearbyPlaceLabels,
|
||||
// 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,
|
||||
},
|
||||
enabledLayerLabels: enabledLayers,
|
||||
...hudSummaryLayerContext(this._dataManager?.getAll?.() || []),
|
||||
};
|
||||
// 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() {
|
||||
|
||||
@@ -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 () => ({ placeLabels: [place] });
|
||||
hud._summaryContext = async () => ({ 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, { placeLabels: ['Atlantic'] });
|
||||
assert.deepEqual(pending[1].context, { place: '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 () => ({ placeLabels: ['Yellowstone'] });
|
||||
hud._summaryContext = async () => ({ place: '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 () => ({ placeLabels: ['San Francisco'] });
|
||||
hud._summaryContext = async () => ({ place: '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 () => ({ placeLabels: ['Lisboa'] });
|
||||
hud._summaryContext = async () => ({ place: 'Lisboa' });
|
||||
const element = env.elements.get('hud-summary');
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
hud._updateCameraData();
|
||||
@@ -501,17 +501,15 @@ 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, and the
|
||||
// plain line replaces the fragment.
|
||||
// A reply still typing stops when the camera starts moving.
|
||||
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,
|
||||
plain,
|
||||
typed,
|
||||
'no more characters after the move',
|
||||
);
|
||||
} finally {
|
||||
@@ -538,7 +536,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 () => ({ placeLabels: ['San Francisco'] });
|
||||
hud._summaryContext = async () => ({ place: 'San Francisco' });
|
||||
hud._updateCameraData();
|
||||
await hud._updateSummary(true);
|
||||
t.mock.timers.tick(48);
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
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);
|
||||
});
|
||||
+27
-181
@@ -1,10 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -46,189 +43,19 @@ export function isHudSummaryUnconfigured(status, data) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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.',
|
||||
'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.',
|
||||
'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.',
|
||||
'Do not infer from coordinates or invent a place or feed state.',
|
||||
// "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.',
|
||||
'Output exactly 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.
|
||||
@@ -256,13 +83,32 @@ export function hudSummaryLayerContext(layers = [], options) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The warning for the HUD telemetry line when an enabled feed is not
|
||||
* nominal.
|
||||
* 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.
|
||||
* @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);
|
||||
return provenanceTag(feedProvenanceEnvelope(snapshots, options));
|
||||
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());
|
||||
}
|
||||
+26
-275
@@ -1,25 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Readable } from 'node:stream';
|
||||
import { readFileSync } from 'node:fs';
|
||||
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 = {
|
||||
@@ -217,284 +206,46 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('HUD summary instructions ask for the canonical feedState as its own field', () => {
|
||||
test('HUD summary instructions require non-nominal feedState in the five words', () => {
|
||||
assert.match(
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
/Set feedState to feedProvenance\.overall exactly/,
|
||||
/five words MUST include that feedState token/,
|
||||
);
|
||||
assert.match(
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
/do not present the view as live or healthy/,
|
||||
/STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/,
|
||||
);
|
||||
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'));
|
||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/);
|
||||
});
|
||||
|
||||
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\{/,
|
||||
test('the HUD proxy uses the shared provenance instructions', () => {
|
||||
const local = readFileSync(
|
||||
new URL('../server/providers/openai/hud-summary.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.equal(hudSummaryLocale(42), 'en');
|
||||
assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
|
||||
assert.doesNotMatch(local, /enabled-layer text labels/);
|
||||
});
|
||||
|
||||
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' };
|
||||
test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => {
|
||||
const { hudSummaryMatchesProvenance } =
|
||||
await import('./hudSummaryResponse.js');
|
||||
assert.equal(
|
||||
hudSummaryMatchesProvenance(
|
||||
{ summary: 'Остин рейсы данные устарели сейчас', feedState: 'stale' },
|
||||
stale,
|
||||
),
|
||||
hudSummaryMatchesProvenance('Austin flights operating normally today', {
|
||||
overall: 'stale',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hudSummaryMatchesProvenance('Austin stale flights over downtown', {
|
||||
overall: 'stale',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hudSummaryMatchesProvenance(
|
||||
{ summary: 'Austin vuelos en vivo ahora', feedState: 'nominal' },
|
||||
stale,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hudSummaryMatchesProvenance(
|
||||
{ summary: 'Austin stale flights over downtown', feedState: null },
|
||||
stale,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hudSummaryMatchesProvenance(
|
||||
{ summary: 'Austin flights' },
|
||||
{ overall: 'partial' },
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hudSummaryMatchesProvenance(
|
||||
{ summary: 'Austin flights operating normally today' },
|
||||
{ overall: 'nominal' },
|
||||
),
|
||||
hudSummaryMatchesProvenance('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'],
|
||||
});
|
||||
});
|
||||
@@ -562,7 +562,7 @@
|
||||
"Radio volume": "مستوى صوت الراديو",
|
||||
"Radio off": "الراديو متوقف",
|
||||
"STATION SITE": "موقع المحطة",
|
||||
"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 الخاص بك.",
|
||||
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "يتصل التطبيق مباشرةً بمحطة البث بعد الضغط على «تشغيل»، فيظهر لها عنوان IP الخاص بك.",
|
||||
"LOADING": "جارٍ التحميل",
|
||||
"DEGRADED": "متدهور",
|
||||
"STALE": "متقادم",
|
||||
@@ -1146,6 +1146,7 @@
|
||||
"OCEANIA": "أوقيانوسيا",
|
||||
"NORTHERN OCEANIC GRID": "الشبكة المحيطية الشمالية",
|
||||
"SOUTHERN OCEANIC GRID": "الشبكة المحيطية الجنوبية",
|
||||
"{state} {layers}": "{state}: {layers}",
|
||||
"view band|STREET": "شارع",
|
||||
"view band|CITY": "مدينة",
|
||||
"view band|METRO": "حضري",
|
||||
@@ -3223,6 +3224,7 @@
|
||||
"UTILITIES": "الأدوات",
|
||||
"Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم",
|
||||
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
|
||||
"Sea Marks": "علامات بحرية",
|
||||
"Ski Pistes": "مسارات التزلج"
|
||||
"World time": "وقت العالم",
|
||||
"Back to live": "العودة إلى البث المباشر",
|
||||
"Live": "مباشر"
|
||||
}
|
||||
@@ -777,7 +777,7 @@
|
||||
"Stop radio playback": "Radiowiedergabe stoppen",
|
||||
"Radio volume": "Radio-Lautstärke",
|
||||
"STATION SITE": "SENDER-WEBSITE",
|
||||
"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.",
|
||||
"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.",
|
||||
"Intelligence HUD (H)": "Lagebild-HUD (H)",
|
||||
"Layout": "Layout",
|
||||
"HUD layout": "HUD-Layout",
|
||||
@@ -1114,6 +1114,7 @@
|
||||
"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,6 +2944,7 @@
|
||||
"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",
|
||||
"Sea Marks": "Seezeichen",
|
||||
"Ski Pistes": "Skipisten"
|
||||
"World time": "Weltzeit",
|
||||
"Back to live": "Zurück zu live",
|
||||
"Live": "Live"
|
||||
}
|
||||
@@ -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 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.",
|
||||
"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.",
|
||||
"LOADING": "CARGANDO",
|
||||
"DEGRADED": "DEGRADADO",
|
||||
"STALE": "DESACTUALIZADO",
|
||||
@@ -2021,6 +2021,7 @@
|
||||
"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,6 +3014,7 @@
|
||||
"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",
|
||||
"Sea Marks": "Señales marítimas",
|
||||
"Ski Pistes": "Pistas de esquí"
|
||||
"World time": "Hora mundial",
|
||||
"Back to live": "Volver al directo",
|
||||
"Live": "En directo"
|
||||
}
|
||||
@@ -331,6 +331,7 @@
|
||||
"STALE": "OBSOLÈTE",
|
||||
"PARTIAL": "PARTIEL",
|
||||
"FALLBACK": "SECOURS",
|
||||
"{state} {layers}": "{state} : {layers}",
|
||||
"view band|STREET": "RUE",
|
||||
"view band|CITY": "VILLE",
|
||||
"view band|METRO": "MÉTROPOLE",
|
||||
@@ -2818,7 +2819,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 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.",
|
||||
"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.",
|
||||
"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,6 +3014,7 @@
|
||||
"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",
|
||||
"Sea Marks": "Balisage maritime",
|
||||
"Ski Pistes": "Pistes de ski"
|
||||
"World time": "Heure du monde",
|
||||
"Back to live": "Revenir au direct",
|
||||
"Live": "Direct"
|
||||
}
|
||||
@@ -331,6 +331,7 @@
|
||||
"STALE": "OBSOLETO",
|
||||
"PARTIAL": "PARZIALE",
|
||||
"FALLBACK": "RISERVA",
|
||||
"{state} {layers}": "{state} {layers}",
|
||||
"view band|STREET": "STRADA",
|
||||
"view band|CITY": "CITTÀ",
|
||||
"view band|METRO": "METRO",
|
||||
@@ -2818,7 +2819,7 @@
|
||||
"Radio volume": "Volume radio",
|
||||
"STATION SITE": "SITO STAZIONE",
|
||||
"DIRECTORY: RADIO BROWSER": "ELENCO: RADIO BROWSER",
|
||||
"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.",
|
||||
"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.",
|
||||
"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,6 +3014,7 @@
|
||||
"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",
|
||||
"Sea Marks": "Segnalamenti marittimi",
|
||||
"Ski Pistes": "Piste da sci"
|
||||
"World time": "Ora del mondo",
|
||||
"Back to live": "Torna alla diretta",
|
||||
"Live": "Diretta"
|
||||
}
|
||||
@@ -325,6 +325,7 @@
|
||||
"STALE": "更新遅延",
|
||||
"PARTIAL": "部分的",
|
||||
"FALLBACK": "フォールバック",
|
||||
"{state} {layers}": "{state} {layers}",
|
||||
"view band|STREET": "街路",
|
||||
"view band|CITY": "市街",
|
||||
"view band|METRO": "都市圏",
|
||||
@@ -2676,7 +2677,7 @@
|
||||
"Radio volume": "ラジオの音量",
|
||||
"STATION SITE": "放送局サイト",
|
||||
"DIRECTORY: RADIO BROWSER": "ディレクトリ:Radio Browser",
|
||||
"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アドレスは放送局側から見えます。",
|
||||
"Audio connects directly to the broadcaster after you press 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,6 +2874,7 @@
|
||||
"UTILITIES": "ユーティリティ",
|
||||
"Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください",
|
||||
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
|
||||
"Sea Marks": "航路標識",
|
||||
"Ski Pistes": "スキーゲレンデ"
|
||||
"World time": "世界時刻",
|
||||
"Back to live": "ライブに戻る",
|
||||
"Live": "ライブ"
|
||||
}
|
||||
@@ -331,6 +331,7 @@
|
||||
"STALE": "DESATUALIZADO",
|
||||
"PARTIAL": "PARCIAL",
|
||||
"FALLBACK": "ALTERNATIVO",
|
||||
"{state} {layers}": "{state}: {layers}",
|
||||
"view band|STREET": "RUA",
|
||||
"view band|CITY": "CIDADE",
|
||||
"view band|METRO": "METRÓPOLE",
|
||||
@@ -2820,7 +2821,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 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.",
|
||||
"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.",
|
||||
"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,6 +3014,7 @@
|
||||
"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",
|
||||
"Sea Marks": "Sinais marítimos",
|
||||
"Ski Pistes": "Pistas de esqui"
|
||||
"World time": "Hora do mundo",
|
||||
"Back to live": "Voltar ao vivo",
|
||||
"Live": "Ao vivo"
|
||||
}
|
||||
@@ -727,7 +727,7 @@
|
||||
"STATION TAG": "ТЕГ СТАНЦИИ",
|
||||
"DRAG TO TUNE": "ТЯНИТЕ ДЛЯ НАСТРОЙКИ",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ПРОСМОТР · ENTER / SPACE — ВЫБОР",
|
||||
"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-адрес.",
|
||||
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "После нажатия «Пуск» звук идёт напрямую от вещателя, и ему виден ваш IP-адрес.",
|
||||
"Internet radio and local SDR": "Интернет-радио и локальный SDR",
|
||||
"Filter stations by station tag": "Фильтровать станции по тегу",
|
||||
"CONTEXT": "КОНТЕКСТ",
|
||||
@@ -2055,6 +2055,7 @@
|
||||
"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,6 +3084,7 @@
|
||||
"UTILITIES": "ИНСТРУМЕНТЫ",
|
||||
"Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку",
|
||||
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
|
||||
"Sea Marks": "Морские знаки",
|
||||
"Ski Pistes": "Горнолыжные трассы"
|
||||
"World time": "Мировое время",
|
||||
"Back to live": "Вернуться к прямому эфиру",
|
||||
"Live": "Сейчас"
|
||||
}
|
||||
@@ -334,6 +334,7 @@
|
||||
"STALE": "ЗАСТАРІЛО",
|
||||
"PARTIAL": "ЧАСТКОВО",
|
||||
"FALLBACK": "РЕЗЕРВ",
|
||||
"{state} {layers}": "{state}: {layers}",
|
||||
"view band|STREET": "ВУЛИЦЯ",
|
||||
"view band|CITY": "МІСТО",
|
||||
"view band|METRO": "АГЛОМЕРАЦІЯ",
|
||||
@@ -2888,7 +2889,7 @@
|
||||
"Radio volume": "Гучність радіо",
|
||||
"STATION SITE": "САЙТ СТАНЦІЇ",
|
||||
"DIRECTORY: RADIO BROWSER": "КАТАЛОГ: RADIO BROWSER",
|
||||
"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-адресу.",
|
||||
"Audio connects directly to the broadcaster after you press 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,6 +3084,7 @@
|
||||
"UTILITIES": "ІНСТРУМЕНТИ",
|
||||
"Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка",
|
||||
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
|
||||
"Sea Marks": "Морські знаки",
|
||||
"Ski Pistes": "Гірськолижні траси"
|
||||
"World time": "Світовий час",
|
||||
"Back to live": "Повернутися до прямого ефіру",
|
||||
"Live": "Зараз"
|
||||
}
|
||||
@@ -325,6 +325,7 @@
|
||||
"STALE": "过期",
|
||||
"PARTIAL": "部分",
|
||||
"FALLBACK": "备用",
|
||||
"{state} {layers}": "{state} {layers}",
|
||||
"view band|STREET": "街道",
|
||||
"view band|CITY": "城市",
|
||||
"view band|METRO": "都市圈",
|
||||
@@ -2575,7 +2576,7 @@
|
||||
"Radio volume": "电台音量",
|
||||
"STATION SITE": "电台网站",
|
||||
"DIRECTORY: RADIO BROWSER": "目录:RADIO BROWSER",
|
||||
"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。",
|
||||
"Audio connects directly to the broadcaster after you press 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,6 +2874,7 @@
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置",
|
||||
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件",
|
||||
"Sea Marks": "航标",
|
||||
"Ski Pistes": "滑雪道"
|
||||
"World time": "世界时间",
|
||||
"Back to live": "返回实时",
|
||||
"Live": "实时"
|
||||
}
|
||||
@@ -325,6 +325,7 @@
|
||||
"STALE": "過期",
|
||||
"PARTIAL": "部分",
|
||||
"FALLBACK": "備用",
|
||||
"{state} {layers}": "{state} {layers}",
|
||||
"view band|STREET": "街道",
|
||||
"view band|CITY": "城市",
|
||||
"view band|METRO": "都市圈",
|
||||
@@ -2563,7 +2564,7 @@
|
||||
"Radio volume": "電臺音量",
|
||||
"STATION SITE": "電臺網站",
|
||||
"DIRECTORY: RADIO BROWSER": "目錄:RADIO BROWSER",
|
||||
"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。",
|
||||
"Audio connects directly to the broadcaster after you press 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,6 +2874,7 @@
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
|
||||
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
|
||||
"Sea Marks": "航標",
|
||||
"Ski Pistes": "滑雪道"
|
||||
"World time": "世界時間",
|
||||
"Back to live": "返回即時",
|
||||
"Live": "即時"
|
||||
}
|
||||
@@ -89,6 +89,7 @@
|
||||
"Landsat 8/9",
|
||||
"Latin",
|
||||
"Layout",
|
||||
"Live",
|
||||
"London",
|
||||
"Los Angeles",
|
||||
"MIL",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { julianAt } from '../../time/julian.js';
|
||||
import {
|
||||
COURSE_HOLD_SPEED_MPS,
|
||||
lerpAngleDeg,
|
||||
@@ -156,7 +157,7 @@ export function createMotion({
|
||||
const renderTime =
|
||||
sampledRenderTime ??
|
||||
Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(flightState._scratchRenderTime),
|
||||
julianAt(flightState.now(), flightState._scratchRenderTime),
|
||||
-RENDER_DELAY_SEC,
|
||||
flightState._scratchRenderTime,
|
||||
);
|
||||
@@ -332,7 +333,7 @@ export function createMotion({
|
||||
const history = flightState._positionHistory.get(flightState._trackedIcao);
|
||||
if (!history || history.length === 0) return true;
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(),
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
flightState._scratchWarmupTime,
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ function fixture() {
|
||||
_scratchArc: {},
|
||||
_scratchOffset: new Cesium.Cartesian3(),
|
||||
_scratchRenderTime: new Cesium.JulianDate(),
|
||||
now: Date.now,
|
||||
};
|
||||
const motion = createMotion({
|
||||
flightState,
|
||||
@@ -138,7 +139,7 @@ test('fleet render time is shared without resampling or mutating the supplied in
|
||||
assert.ok(Cesium.JulianDate.equals(renderTime, savedTime));
|
||||
});
|
||||
|
||||
test('tracked callers still sample a fresh delayed time without a fleet epoch', (t) => {
|
||||
test('tracked callers still sample a fresh delayed time without a fleet epoch', () => {
|
||||
const { flightState, motion } = fixture();
|
||||
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
|
||||
const history = [
|
||||
@@ -156,10 +157,11 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch',
|
||||
flightState.records.data.set('tracked', { velocity: 100, true_track: 90 });
|
||||
flightState._positionHistory.set('tracked', history);
|
||||
let elapsed = 60;
|
||||
const spy = t.mock.method(Cesium.JulianDate, 'now', (result) => {
|
||||
assert.equal(result, flightState._scratchRenderTime);
|
||||
return Cesium.JulianDate.addSeconds(start, elapsed, result);
|
||||
});
|
||||
let reads = 0;
|
||||
flightState.now = () => {
|
||||
reads += 1;
|
||||
return Cesium.JulianDate.toDate(start).getTime() + elapsed * 1000;
|
||||
};
|
||||
const out = new Cesium.Cartesian3();
|
||||
close(
|
||||
motion._deadReckon('tracked', out),
|
||||
@@ -180,5 +182,5 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch',
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
);
|
||||
assert.equal(spy.mock.callCount(), 2);
|
||||
assert.equal(reads, 2);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { julianAt } from '../../time/julian.js';
|
||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||
import { civilAircraftModelSpec } from './modelSpec.js';
|
||||
@@ -863,7 +864,7 @@ export function createRendering({
|
||||
flightState._lastFleetTickMs = nowMs;
|
||||
// Every contact in this pass shares the same delayed display instant.
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(flightState._scratchFleetRenderTime),
|
||||
julianAt(flightState.now(), flightState._scratchFleetRenderTime),
|
||||
-RENDER_DELAY_SEC,
|
||||
flightState._scratchFleetRenderTime,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FlightRecords } from './records.js';
|
||||
import { createFlightFeed } from './ingestion.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js';
|
||||
|
||||
export function createFlightState({ source, services }) {
|
||||
@@ -12,6 +13,8 @@ export function createFlightState({ source, services }) {
|
||||
...services.groundFloor,
|
||||
});
|
||||
flightState._cullPositions = new Map();
|
||||
/** The time contacts are drawn behind by the render delay, in epoch ms. */
|
||||
flightState.now = worldTimeFor('flights');
|
||||
|
||||
/** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs
|
||||
* vertex-baked to real-world METERS in the airplane.glb axis convention, so
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { julianAt } from '../../time/julian.js';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
import { unitLabel } from '../../ui/unitLabels.js';
|
||||
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
|
||||
@@ -383,7 +384,7 @@ export function createTracking({
|
||||
// would draw the trail in front of the plane. They join the trail via _appendTrailFix
|
||||
// as they age past the delay.
|
||||
const seedRenderTime = Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(),
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
flightState._scratchWarmupTime,
|
||||
);
|
||||
|
||||
@@ -69,6 +69,8 @@ export function createGnssIntegrityLayer({
|
||||
source,
|
||||
viewAnchor = defaultGnssViewAnchor,
|
||||
now = () => Date.now(),
|
||||
// The end of the window shown; retries and receipt times stay on `now`.
|
||||
worldNow = now,
|
||||
monotonicNow = () => performance.now(),
|
||||
windowMs: requestedWindowMs,
|
||||
} = {}) {
|
||||
@@ -163,7 +165,7 @@ export function createGnssIntegrityLayer({
|
||||
|
||||
/** Age the store without new evidence, so expired cells leave the globe. */
|
||||
function expire() {
|
||||
pruneGnssObservations(_observations, now(), { windowMs });
|
||||
pruneGnssObservations(_observations, worldNow(), { windowMs });
|
||||
_cells = binGnssCells(_observations);
|
||||
render();
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -26,7 +26,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
getAnalystRecords(maxCount = Infinity) {
|
||||
if (!layerState._enabled) return [];
|
||||
const now = Date.now();
|
||||
const now = layerState.now();
|
||||
const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
||||
const result = [];
|
||||
for (const launch of layerState._launches || []) {
|
||||
|
||||
@@ -222,6 +222,7 @@ export function createIngestion({
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._lastError = null;
|
||||
layerState._stale = stale === true;
|
||||
layerState.coverageChanged();
|
||||
} catch (error) {
|
||||
if (layerState._enabled && token === layerState._lifecycleToken) {
|
||||
layerState._lastError = error.message;
|
||||
|
||||
@@ -105,7 +105,7 @@ export function createLaunchPad({
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
if (nowMs - layerState._lastMissionRingRotationMs >= 1000) {
|
||||
layerState._missionRingDate.setTime(nowMs);
|
||||
layerState._missionRingDate.setTime(layerState.orbitNow());
|
||||
parts.orbitRendering.updateMissionOrbitPrimitiveFrames(
|
||||
layerState._missionRingDate,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
|
||||
const HOVER_PICK_INTERVAL_MS = 120;
|
||||
|
||||
@@ -119,6 +120,21 @@ export function createLifecycle({
|
||||
}) {
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
let hover = null;
|
||||
// While on, the launches loaded span the time from the first to the last,
|
||||
// each one a mark. Registered once; off, the layer has no time to show, and
|
||||
// each step tells the clock once it has settled.
|
||||
worldClock.register({
|
||||
id: 'rocket-launches',
|
||||
coverage: () => {
|
||||
if (!layerState._enabled) return null;
|
||||
const marks = layerState._launches
|
||||
.map((launch) => Date.parse(launch.launchTime))
|
||||
.filter(Number.isFinite)
|
||||
.sort((a, b) => a - b);
|
||||
if (!marks.length) return null;
|
||||
return { start: marks[0], end: marks.at(-1), marks };
|
||||
},
|
||||
});
|
||||
|
||||
const methods = {
|
||||
init(viewer) {
|
||||
@@ -194,6 +210,7 @@ export function createLifecycle({
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
layerState.coverageChanged();
|
||||
},
|
||||
|
||||
async _enableBody() {
|
||||
@@ -231,6 +248,7 @@ export function createLifecycle({
|
||||
parts.overlays.clearMissionOverlaySources();
|
||||
await parts.ingestion.restoreSatelliteDependency();
|
||||
parts.panel.renderMissionPanel();
|
||||
layerState.coverageChanged();
|
||||
},
|
||||
|
||||
async destroy(viewer) {
|
||||
|
||||
@@ -124,7 +124,7 @@ export function createOrbitRendering({
|
||||
collection.show = missionOrbitPrimitiveVisible(launch.id);
|
||||
orbitFrameModelMatrix(
|
||||
satelliteTrack.gmstAtBake,
|
||||
new Date(),
|
||||
new Date(layerState.orbitNow()),
|
||||
collection.modelMatrix,
|
||||
);
|
||||
layerState._viewer.scene.primitives.add(collection);
|
||||
|
||||
@@ -394,7 +394,7 @@ export function createRendering({
|
||||
orbitCurrent.latitude,
|
||||
orbitCurrent.altitude,
|
||||
);
|
||||
const liveTime = new Date();
|
||||
const liveTime = new Date(layerState.orbitNow());
|
||||
const liveTelemetry = { speedMps: null };
|
||||
layerState._satelliteTelemetry.set(launch.id, liveTelemetry);
|
||||
let liveFrameNumber = -1;
|
||||
@@ -405,7 +405,7 @@ export function createRendering({
|
||||
layerState._viewer?.scene?.frameState?.frameNumber ?? -1;
|
||||
if (frameNumber !== -1 && frameNumber === liveFrameNumber) return;
|
||||
liveFrameNumber = frameNumber;
|
||||
const nowMs = Date.now();
|
||||
const nowMs = layerState.orbitNow();
|
||||
liveTime.setTime(nowMs);
|
||||
if (satelliteTrack) {
|
||||
const propagated =
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { worldClock, worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
export function createState({ services }) {
|
||||
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
|
||||
@@ -147,6 +148,18 @@ export function createState({ services }) {
|
||||
|
||||
state._missionRingDate = new Date(0);
|
||||
|
||||
/** The time the launch window ends at, in epoch ms. */
|
||||
|
||||
state.now = worldTimeFor('rocket-launches');
|
||||
|
||||
/** A launched mission's satellite flies on the satellites layer's time. */
|
||||
|
||||
state.orbitNow = worldTimeFor('satellites');
|
||||
|
||||
/** The launches loaded changed, and with them the time they cover. */
|
||||
|
||||
state.coverageChanged = () => worldClock.coverageChanged();
|
||||
|
||||
state._missionOrbitPatternRegistered = false;
|
||||
|
||||
state._pathDistanceCache = new WeakMap();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { julianAt } from '../../time/julian.js';
|
||||
import {
|
||||
lerpAngleDeg,
|
||||
speedRamp,
|
||||
@@ -67,7 +68,7 @@ export function createMotion({
|
||||
// Render one poll interval behind real time so we interpolate between
|
||||
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(),
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
flightState._scratchRenderTime,
|
||||
);
|
||||
@@ -213,7 +214,7 @@ export function createMotion({
|
||||
const history = flightState._positionHistory.get(flightState._trackedIcao);
|
||||
if (!history || history.length === 0) return true;
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(),
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
flightState._scratchWarmupTime,
|
||||
);
|
||||
|
||||
@@ -28,29 +28,6 @@ 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 = {
|
||||
@@ -145,7 +122,26 @@ 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');
|
||||
const supplied = await sceneServices();
|
||||
// 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 fixLon = -97.6;
|
||||
const fixLat = 30.3;
|
||||
const speedMps = 200;
|
||||
@@ -217,55 +213,6 @@ 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 };
|
||||
|
||||
@@ -187,12 +187,9 @@ export class MilitaryFlightRecords {
|
||||
fixEpochMs: aircraft.positionTimeMs ?? observedAtMs,
|
||||
};
|
||||
}
|
||||
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.
|
||||
absence(id, { complete, likelyLanded }) {
|
||||
if (
|
||||
(!complete || (retainIds?.has(id) && !likelyLanded)) &&
|
||||
!complete &&
|
||||
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
|
||||
)
|
||||
return 'retain';
|
||||
|
||||
@@ -99,30 +99,6 @@ 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(
|
||||
|
||||
@@ -259,7 +259,6 @@ 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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MilitaryFlightRecords } from './records.js';
|
||||
import { createMilitaryFeed } from './ingestion.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
export function createFlightState({ source, services }) {
|
||||
const { createGroundSnap } = services.groundSnap;
|
||||
@@ -11,6 +12,8 @@ export function createFlightState({ source, services }) {
|
||||
...services.groundFloor,
|
||||
});
|
||||
flightState._cullPositions = new Map();
|
||||
/** The time contacts are drawn behind by the render delay, in epoch ms. */
|
||||
flightState.now = worldTimeFor('military');
|
||||
|
||||
/** Per-class model spec for THIS layer (2026-08-16, owner playtest ask:
|
||||
* military contacts should read as their WEIGHT CLASS, always in this layer's
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { julianAt } from '../../time/julian.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
|
||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||
@@ -500,7 +501,7 @@ export function createTracking({
|
||||
// newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them
|
||||
// would draw the trail in front of the plane. They join via _appendTrailFix as they age.
|
||||
const seedRenderTime = Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(),
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
flightState._scratchWarmupTime,
|
||||
);
|
||||
|
||||
@@ -344,17 +344,14 @@ export function createPlayback({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
if (!parts.interaction.radioPresentationAllowed())
|
||||
return Promise.resolve(false);
|
||||
if (layerState._selectedId) {
|
||||
// Drop a fallback left by a voice step whose playback never started.
|
||||
layerState._playFallbackId = null;
|
||||
layerState._playFallbackFocus = null;
|
||||
} else {
|
||||
if (!layerState._selectedId) {
|
||||
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 });
|
||||
|
||||
@@ -73,16 +73,6 @@ 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(
|
||||
@@ -93,18 +83,11 @@ 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(
|
||||
@@ -121,7 +104,6 @@ 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;
|
||||
@@ -140,9 +122,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -154,7 +133,6 @@ export function createSelection({
|
||||
rotate = false,
|
||||
stationIds = null,
|
||||
autoplay = true,
|
||||
fallback = autoplay,
|
||||
origin = 'programmatic',
|
||||
} = {},
|
||||
) {
|
||||
@@ -181,29 +159,25 @@ export function createSelection({
|
||||
current < 0
|
||||
? 0
|
||||
: (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length;
|
||||
// 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;
|
||||
layerState._playFallbackId =
|
||||
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;
|
||||
const fallbackFocus =
|
||||
fallback && rotate
|
||||
? (fallbackStation) => {
|
||||
parts.navigation.rotateRadioStationIntoView(
|
||||
fallbackStation,
|
||||
0.65,
|
||||
rotationCameraState,
|
||||
rotationNavigation,
|
||||
);
|
||||
return rotationNavigation;
|
||||
}
|
||||
: false;
|
||||
layerState._playFallbackFocus = rotate
|
||||
? (fallbackStation) => {
|
||||
parts.navigation.rotateRadioStationIntoView(
|
||||
fallbackStation,
|
||||
0.65,
|
||||
rotationCameraState,
|
||||
rotationNavigation,
|
||||
);
|
||||
return rotationNavigation;
|
||||
}
|
||||
: false;
|
||||
const station = ranked[nextIndex];
|
||||
if (rotate)
|
||||
parts.navigation.rotateRadioStationIntoView(
|
||||
@@ -217,7 +191,6 @@ export function createSelection({
|
||||
focus: false,
|
||||
origin,
|
||||
cameraNavigation: rotationNavigation,
|
||||
playFallback: { id: fallbackId, focus: fallbackFocus },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -244,12 +217,9 @@ export function createSelection({
|
||||
},
|
||||
);
|
||||
if (!ranked.length) return null;
|
||||
selectRadioStation(ranked[0].id, {
|
||||
autoplay,
|
||||
focus: false,
|
||||
origin,
|
||||
playFallback: { id: ranked[1]?.id || null, focus: false },
|
||||
});
|
||||
layerState._playFallbackId = ranked[1]?.id || null;
|
||||
layerState._playFallbackFocus = false;
|
||||
selectRadioStation(ranked[0].id, { autoplay, focus: false, origin });
|
||||
return ranked[0];
|
||||
}
|
||||
|
||||
|
||||
@@ -78,8 +78,6 @@ export function createState({ services }) {
|
||||
|
||||
layerState._tuningStartStationId = null;
|
||||
|
||||
layerState._tuningResumesPlayback = false;
|
||||
|
||||
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
||||
|
||||
layerState._tuningStationById = new Map();
|
||||
|
||||
@@ -66,12 +66,6 @@ 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();
|
||||
@@ -185,9 +179,7 @@ export function createTuning({ state: layerState, services, parts, source }) {
|
||||
layerState._selectionGeneration += 1;
|
||||
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
|
||||
layerState._tuningUnavailableStationId = stationId;
|
||||
// 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 });
|
||||
parts.presentation.emitState();
|
||||
return Object.freeze({
|
||||
ok: false,
|
||||
reason: 'station-unavailable',
|
||||
@@ -195,11 +187,9 @@ export function createTuning({ state: layerState, services, parts, source }) {
|
||||
generation,
|
||||
});
|
||||
}
|
||||
const autoplay = layerState._tuningResumesPlayback;
|
||||
layerState._tuningResumesPlayback = false;
|
||||
layerState._tuningActive = false;
|
||||
layerState._tuningStatic = autoplay;
|
||||
layerState._tuningAwaitingStationId = autoplay ? frozenStation.id : null;
|
||||
layerState._tuningStatic = true;
|
||||
layerState._tuningAwaitingStationId = frozenStation.id;
|
||||
layerState._tuningPreviewId = null;
|
||||
layerState._tuningStartStationId = null;
|
||||
layerState._tuningUnavailableStationId = null;
|
||||
@@ -214,12 +204,10 @@ export function createTuning({ state: layerState, services, parts, source }) {
|
||||
layerState._playFallbackFocus = null;
|
||||
layerState._playFallbackOrigin = 'programmatic';
|
||||
layerState._playFallbackAttemptId = null;
|
||||
// Muting alone would leave the noise graph running after a silent release.
|
||||
if (autoplay) parts.tuningNoise.syncTuningNoiseGain();
|
||||
else parts.tuningNoise.clearRadioTuningNoise({ emit: false });
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
parts.presentation.emitState();
|
||||
const selected = parts.selection.selectRadioStation(frozenStation.id, {
|
||||
autoplay,
|
||||
autoplay: true,
|
||||
focus: false,
|
||||
origin,
|
||||
cameraNavigation,
|
||||
|
||||
@@ -50,11 +50,6 @@ 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({
|
||||
@@ -84,21 +79,8 @@ export function createTuningNoise({
|
||||
if (!AudioContextClass) return false;
|
||||
if (!layerState._tuningNoiseContext)
|
||||
layerState._tuningNoiseContext = new AudioContextClass();
|
||||
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();
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
const resumed = layerState._tuningNoiseContext.resume?.();
|
||||
if (resumed?.catch) void resumed.catch(() => {});
|
||||
if (layerState._tuningNoiseSource) return true;
|
||||
|
||||
const frameCount = Math.max(
|
||||
@@ -138,7 +120,6 @@ export function createTuningNoise({
|
||||
if (layerState._tuningCameraNavigation)
|
||||
parts.navigation.invalidateRadioCameraNavigation();
|
||||
layerState._tuningActive = false;
|
||||
layerState._tuningResumesPlayback = false;
|
||||
layerState._tuningStatic = false;
|
||||
layerState._tuningAwaitingStationId = null;
|
||||
layerState._tuningPreviewId = null;
|
||||
@@ -150,7 +131,8 @@ export function createTuningNoise({
|
||||
parts.rendering.updateSelectionEntity();
|
||||
syncTuningNoiseGain();
|
||||
stopTuningNoiseSource();
|
||||
suspendTuningNoiseContext();
|
||||
const suspended = layerState._tuningNoiseContext?.suspend?.();
|
||||
if (suspended?.catch) void suspended.catch(() => {});
|
||||
if (emit) parts.presentation.emitState();
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -32,7 +32,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
1,
|
||||
Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES),
|
||||
);
|
||||
const now = new Date();
|
||||
const now = new Date(layerState.now());
|
||||
for (let i = 0; i < perFrame; i++) {
|
||||
if (layerState._denseCursor >= layerState._denseIds.length)
|
||||
layerState._denseCursor = 0;
|
||||
@@ -97,7 +97,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
|
||||
const entries = parts.orbits.parseTLE(text);
|
||||
const style = POINT_STYLES.dense;
|
||||
const now = new Date();
|
||||
const now = new Date(layerState.now());
|
||||
let added = 0;
|
||||
|
||||
for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) {
|
||||
@@ -165,6 +165,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
|
||||
layerState._count = layerState._points.size;
|
||||
layerState._catalogRevision++;
|
||||
layerState.coverageChanged();
|
||||
layerState._denseStatus = 'ready';
|
||||
console.log(
|
||||
`[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`,
|
||||
@@ -232,6 +233,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
layerState._denseCursor = 0;
|
||||
layerState._count = layerState._points.size;
|
||||
layerState._catalogRevision++;
|
||||
layerState.coverageChanged();
|
||||
}
|
||||
return {
|
||||
_abortActiveUpdates,
|
||||
|
||||
Vendored
+4
-1
@@ -190,7 +190,10 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
if (noradId === null) return null;
|
||||
|
||||
const sat = layerState._catalog.get(noradId);
|
||||
const pos = parts.orbits.propagatePosition(sat.satrec, new Date());
|
||||
const pos = parts.orbits.propagatePosition(
|
||||
sat.satrec,
|
||||
new Date(layerState.now()),
|
||||
);
|
||||
if (!pos) return null;
|
||||
|
||||
return {
|
||||
|
||||
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user