mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge remote-tracking branch 'upstream/main' into fix/drone-airspace
# Conflicts: # docs/CURRENT-STATE.md # src/app/constructCatalog.test.mjs # src/app/sourceComposition.test.mjs # src/data/layerState.test.mjs # src/data/layerStateTokenLedger.test.mjs # src/i18n/locales/ar.json
This commit is contained in:
195 files changed
+23414
-1578
No files matched your search
@@ -263,6 +263,7 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||
|
||||
# Optional: comma-separated Overpass URLs; use only instances you run or pay for.
|
||||
# Also enables voice place search (osm_query, "hospitals in Kathmandu").
|
||||
OVERPASS_UPSTREAMS=
|
||||
|
||||
# Optional: place-lookup endpoints. Lookups try bundled data first, then the
|
||||
@@ -295,3 +296,8 @@ 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
|
||||
+7
-1
@@ -99,6 +99,8 @@ How to read this:
|
||||
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
|
||||
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
|
||||
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
|
||||
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
|
||||
| **OpenSnowMap** (`www.opensnowmap.org`) | Ski Pistes layer: raster overlay of ski runs and lifts (#10) | Tiles CC BY-SA; data ODbL 1.0 via OpenStreetMap; bulk tile download forbidden without prior agreement | "Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)" |
|
||||
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover |
|
||||
|
||||
### Local receiver input (not fetched from a service)
|
||||
@@ -119,6 +121,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
|
||||
|
||||
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
||||
- **Sea Marks and Ski Pistes (#10).** Two keyless raster overlay layers under Infrastructure, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline.
|
||||
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
|
||||
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
|
||||
<a id="gnss-integrity"></a>
|
||||
@@ -181,7 +184,9 @@ 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 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.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -190,6 +195,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage.
|
||||
- **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon (`PHOTON_URL`; unset uses the public instance, empty disables it), then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` and the voice outline route `/api/geocode/outline` share one Nominatim queue (`NOMINATIM_URL`; unset, empty and invalid values disable it; an operator must explicitly select an authorized endpoint): one request at a time at least 1.1 s apart, an identifying `User-Agent` and `Referer`, a short queue that refuses a burst, a pause after 429/403/5xx answers (honouring `Retry-After` in full), at most one retry after a network failure, and a per-install daily cap on public-instance requests (`NOMINATIM_DAILY_CAP`, default 50). Processes using the same installation state file hold one lock through the actual fetch invocation and recording the next public start, with the daily slot persisted before invocation; restarts share that count, spacing and any pause. An unfinished dispatch marker fails closed until operator recovery; it is never automatically reclaimed. Public access fails closed if the state file cannot be read, locked, written or semantically validated. This coordination does not span independent installations or state directories and does not establish an application-wide public-service allowance or authorize public use. An operator-selected non-public endpoint is outside the public cap, spacing and durable-state requirement; an upstream cooldown is kept with the endpoint that returned it rather than leaking across operator URLs. Answers are cached on disk (outlines 90 days, places 30 days, no-results 24 hours); failures are not cached. Outlines are looked up only for an explicit ask, after bundled data and any configured Overpass, and only results whose type suits the ask are used. Administrative outline responses expose country/admin-1/admin-2 identity only when the selected Nominatim row has a recognized, unambiguous address type consistent with its own name; numeric rank, ambiguous types, missing fields and contradictory names do not manufacture identity. It is not used for systematic or bulk queries.
|
||||
- **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name.
|
||||
- **Voice areas and place search.** Voice areas (`resolve_area`) use the same outline sources as voice outlines. Voice place search (`osm_query`, `/api/osm/features`) runs only on an operator-configured Overpass: a fixed list of kinds, a bounded box and result count, one query at a time, answers cached for a day. It never uses Nominatim.
|
||||
- **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and resolves physical regions from bundled public-domain Natural Earth polygons without reverse-geocoding requests. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit.
|
||||
- **Dynamic weather presentation.** While cockpit mode is active, `/api/weather-effects` requests current Open-Meteo observations for the aircraft/camera location, rounds coordinates into 0.1° cache cells, caches results for five minutes, and may retain a stale observation for up to 30 minutes during a transient outage. WMO condition code selects the visual family; observed cloud cover, precipitation, visibility, wind speed, and wind direction bound its strength and motion. Missing or expired weather renders no synthetic atmospheric effect, and normal globe view never renders the weather overlay.
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
**Start without an account or API keys.** Every path opens the same app with
|
||||
**Local setup needs no account or API keys.** The app opens with
|
||||
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
|
||||
unreachable. Flights, military traffic, satellites, earthquakes, public
|
||||
cameras, radio, and launches are available without keys.
|
||||
@@ -212,7 +212,7 @@ Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front o
|
||||
|
||||
On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`.
|
||||
|
||||
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)).
|
||||
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). The map routes compare the browser's `Origin` with the `Host` the server sees, so the proxy must pass the original `Host` and, when it terminates TLS, `X-Forwarded-Proto`. Caddy does both by default; for nginx add `proxy_set_header Host $http_host;`, `proxy_set_header X-Forwarded-Proto $scheme;` and `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`. On an install outside Docker, that forwarding header also rate-limits the proxy's users apart from your own browser. Under the default Docker bridge network every client on the host, your browser included, reaches the container from its gateway address and shares one Overpass bucket.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -236,7 +236,11 @@ the container.
|
||||
|
||||
### Path 4 — GitHub Codespaces (cloud, no local install)
|
||||
|
||||
Runs the same dev server in a cloud workspace, so Node and the dependencies
|
||||
Codespaces requires a GitHub account. Overpass queries and OpenAI voice token
|
||||
requests return HTTP 403 through its forwarded HTTPS URL, even with API keys
|
||||
in `.env` and `GEV_ALLOWED_HOSTS` configured. Use a local setup for these features.
|
||||
|
||||
The codespace runs the dev server in a cloud workspace, so Node and the dependencies
|
||||
never touch your machine. The globe still renders in your own browser, so it
|
||||
needs the same graphics there as any other path. GitHub Free includes 120
|
||||
core-hours a month with no card on file — about 60 hours on the default 2-core
|
||||
@@ -265,14 +269,13 @@ ignored on purpose, so pass that exact name.
|
||||
|
||||
⚠️ **A codespace is reachable from the internet.** Treat it as the
|
||||
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
|
||||
Settings does not open on the forwarded URL, so put keys in the workspace
|
||||
`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the
|
||||
threat model.
|
||||
Settings is unavailable on the forwarded URL. Never commit your workspace
|
||||
`.env`. [SECURITY.md](SECURITY.md) has the threat model.
|
||||
|
||||
### Then power it up — in the app, not in a file
|
||||
|
||||
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
|
||||
chip in the bottom-right corner: Provider Settings lists every supported key,
|
||||
For a local setup, keys are upgrades, not prerequisites. When you want one,
|
||||
click the **POWER UP** chip in the bottom-right corner: Provider Settings lists every supported key,
|
||||
what it switches on, and where to get it. Paste, hit **SAVE KEYS**, and the app
|
||||
restarts itself with the new capability on. Once everything is configured the
|
||||
chip reads **POWERED UP** — and if a compact layout hides it, `?setup=1`
|
||||
@@ -442,6 +445,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer
|
||||
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
|
||||
| ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise |
|
||||
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) |
|
||||
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
|
||||
@@ -452,7 +456,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer
|
||||
|
||||
**Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view.
|
||||
|
||||
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Audio; Directions sits in the separate **Utilities** panel with Draw and Import.
|
||||
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Audio; Directions and OSM Places sit in the separate **Utilities** panel with Draw and Import.
|
||||
|
||||
**The basemap ladder — what each tier buys you:**
|
||||
|
||||
|
||||
+2
-1
@@ -74,13 +74,14 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
|
||||
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
|
||||
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
|
||||
- **Voice place search runs only on the operator's Overpass.** `/api/osm/features` answers only when `OVERPASS_UPSTREAMS` is set and never falls back to a public instance. The client sends a curated preset id, a box, a mode and a limit; the server writes and quotes the Overpass query itself, so no client or model text reaches it. Boxes are capped (36 deg² for a count, 4 deg² for a listing), as are the feature limit, the Overpass timeout and memory, and the response bytes read. Each client (IPv6 clients keyed by /64, as on `/api/overpass`) runs one search at a time and two run at once in all, under a per-client rate limit (12/min) and a global one (30/min); a search gets 22 s across every configured endpoint in turn (Overpass itself is told 20 s), which ends before the voice answer stops waiting at 25 s. The route sits behind the cross-site gate below. Answers are cached in memory and on disk (`.gev-cache/osm-features/`, at most 400 entries and 256 MB, kept a day), keyed to the configured instance so a repointed `OVERPASS_UPSTREAMS` never serves the old one's answers; a malformed answer is never cached.
|
||||
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
|
||||
- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one.
|
||||
- **Response-size caps and timeouts** on proxied responses.
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator 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 and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
|
||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
|
||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
- Add Sea Marks (OpenSeaMap) and Ski Pistes (OpenSnowMap) as two raster
|
||||
overlay layers under Infrastructure. They drape on the globe stacks and on
|
||||
Google 3D, and share links keep each one (Tom-Neverwinter, #114).
|
||||
@@ -0,0 +1,6 @@
|
||||
- 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).
|
||||
@@ -0,0 +1,13 @@
|
||||
- Keep Overpass-backed layers working behind a reverse proxy: `/api/overpass`
|
||||
now uses the keyless same-site gate, admits forwarding headers and still
|
||||
refuses cross-site requests. Direct loopback requests keep their own
|
||||
admission window; proxied requests, including those a local reverse proxy
|
||||
relays over loopback, are admitted as remote traffic keyed by their socket,
|
||||
and IPv6 peers are keyed by /64, so neither LAN peers nor proxied users can
|
||||
exhaust the owner's admission. The upstream budget stays shared, so an owner
|
||||
cache miss can still be rate-limited (asturksever, #1308).
|
||||
- Give the tracking arrival check a wall-clock budget per observed frame and
|
||||
retry only an inconclusive timeout: a timeout keeps every angle it
|
||||
measured, including a late frame's, and a wrong one fails without retry, so slow software-rendered
|
||||
frames no longer fail the check and a real violation is never retried into a
|
||||
pass (asturksever, #1308).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Military aircraft that leave the adsb.lol feed fade and are removed again.
|
||||
The feed's routine position-less rows no longer mark every snapshot partial,
|
||||
which kept vanished aircraft at full opacity for up to five minutes; those
|
||||
rows keep only their own aircraft, and a likely-landed one still leaves on
|
||||
the landed limit (Aditya, @Rohilalala, #1435).
|
||||
@@ -0,0 +1,3 @@
|
||||
- A terrain request the proxy can only partly answer (a 429 pause, or a point
|
||||
the upstream returns without a height) now keeps the real heights it has
|
||||
instead of putting the whole chunk on the geoid (quanticsoul4772, #1456).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Radio Next, Previous and the tuning dial no longer start a broadcaster stream
|
||||
before Play is pressed; while a stream plays, it follows the new station.
|
||||
Play reads Pause while a stream connects, and a tuner release onto a station
|
||||
the directory removed or changed no longer leaves a Resume for another
|
||||
station (gekh, #1474).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Write the AI HUD summary in the interface language, led by the HUD's own
|
||||
translated feed-state warning. A partial layer no longer reads as off or
|
||||
nominal in the HUD, voice and analyst answers (German, @gekh, #1488).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Street Traffic gives the two TomTom `one_side` lines of one two-way street
|
||||
a two-way direction's half share of simulated vehicles each, so the street
|
||||
is no longer drawn twice as dense as the same street from OpenStreetMap
|
||||
(quanticsoul4772, #1495).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Draw the Global Context 250 km range ring as a 4 px line with a dark halo,
|
||||
so it stays visible over desert, snow, ocean and forest and under the FLIR,
|
||||
NVG and CRT styles, where the old 1 px outline disappeared (manjunath22466,
|
||||
#1518).
|
||||
@@ -0,0 +1,8 @@
|
||||
- The Global Context range ring pulses like a sonar: three dim rings with a
|
||||
soft inward glow grow from the subject to 250 km, and the ring flashes as
|
||||
each one arrives. Flights, military aircraft and AIS vessels a pulse crosses
|
||||
get a brief echo around their brackets. The pulses run for about 9 s after
|
||||
a selection or camera move, dimming the ring between flashes, then rest
|
||||
with the ring steady on screen, so a parked view stops repainting. Under
|
||||
reduced motion there are no pulses and the ring stays on screen
|
||||
(manjunath22466, #1519).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Keep the globe on screen when zooming out from a tilted view. User zoom is
|
||||
capped at 40,000 km, and above 300 km outward zoom pitches the view down so
|
||||
Earth stays framed. Correction finishes when zoom stops, respects tilt
|
||||
gestures and stricter host limits, and yields to other camera actions
|
||||
(asturksever, #1522).
|
||||
@@ -0,0 +1,15 @@
|
||||
- Voice can reuse what it outlines. `resolve_area` turns a named place into a
|
||||
reusable area, keeping every part and hole, from the same sources as voice
|
||||
outlines; a name several countries or states share ("Georgia", "Punjab") is
|
||||
asked about unless the view is inside one of them. "Draw an area around
|
||||
<landmark>" outlines the mapped area enclosing it, or a 250 m buffer marked
|
||||
approximate. `analyst_query` counts inside an area, the area you drew or an
|
||||
outline on the map, and `find_imagery` shows the best recent acquisition for
|
||||
an area, the view or the spot under the pointer (manjunath22466, #846).
|
||||
- `osm_query` ("hospitals in Kathmandu") lists places of one kind into a
|
||||
session-only OSM Places layer. It is offered only when `OVERPASS_UPSTREAMS`
|
||||
is set, and `/api/osm/features` refuses before fetching otherwise; public
|
||||
Nominatim is never used for it (manjunath22466, #846).
|
||||
- Analyst counts that combine flights, military aircraft and local ADS-B are
|
||||
named "aircraft". Follow-ups keep the displayed scope and the loaded-data
|
||||
caveat until a new scope replaces them (manjunath22466, #846).
|
||||
+146
-57
@@ -44,6 +44,27 @@ pinching traverses globe and street scales with fewer gestures. Cesium retains
|
||||
its distance-aware zoom, collision handling and minimum/maximum distance limits.
|
||||
Fine-pointer desktop mouse and trackpad sensitivity is unchanged.
|
||||
|
||||
User zoom is capped at 40,000 km (or a stricter host limit). Explicit camera
|
||||
flight, share-link and story destinations can go farther; from there, user zoom
|
||||
can't climb any higher and zooms in gradually from the current height, with no
|
||||
jump to the cap. Cesium can still use the limit when it computes an automatic
|
||||
bounding-sphere framing distance.
|
||||
Above 300 km, outward movement from the controller's configured zoom bindings
|
||||
(wheel, pinch distance and unmodified right-drag by default) pitches the camera
|
||||
down towards 8° below the Earth's limb. Large steps reach the limb immediately;
|
||||
the remaining margin eases into place, including after zooming stops or reaches
|
||||
the ceiling. Continuous outward inertia remains guarded without a time cutoff.
|
||||
Two-finger tilts, modifier-specific tilts and other camera gestures cancel the
|
||||
correction. A two-finger gesture counts as a pinch zoom only while the finger
|
||||
spacing changes more than the fingers move up or down, so spacing drift during
|
||||
a tilt is ignored. Explicit navigation handoffs, including toolbar tilt,
|
||||
cancel it before animation begins. Zooming in, disabled zoom/input, non-3D modes, tracked
|
||||
transforms and flights also cancel it; external pose changes cancel stationary
|
||||
settling. Oblique views below 300 km are left alone.
|
||||
|
||||
`QA_BASE_URL=http://localhost:4173 node scripts/qa-zoom-out-guard.mjs` exercises
|
||||
the real browser controller at desktop and phone widths with keyless fixtures.
|
||||
|
||||
Analyst region queries resolve unique Natural Earth names offline without
|
||||
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
|
||||
Duplicate names require a geocoded containment anchor; when several entries for
|
||||
@@ -80,7 +101,7 @@ implementations.
|
||||
## Prohibited & Restricted Airspace — FAA special-use airspace
|
||||
|
||||
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
|
||||
share-link token `6`), off by default. `/api/drone-airspace` pages the public
|
||||
share-link token `8`), off by default. `/api/drone-airspace` pages the public
|
||||
FAA Special Use Airspace FeatureServer for `TYPE_CODE` P (prohibited) and R
|
||||
(restricted), simplifies geometry, caches for an hour, and serves the last
|
||||
good snapshot as stale when a refresh fails. The globe draws ground-clamped
|
||||
@@ -716,8 +737,9 @@ ordinary tool-card limits are unchanged.
|
||||
Voice speech: `src/voice/speech.js` adds `{ say, display, referents?, schedule? }`
|
||||
to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
|
||||
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
|
||||
share one deterministic count/scope headline; lower bounds use "At least" and
|
||||
partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
|
||||
share one bounded, lossless count/scope headline; combined flight, military and
|
||||
local ADS-B answers use the shared noun "aircraft"; lower bounds use "At least"
|
||||
and partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
|
||||
FIRMS and ALPR entity labels remain English in shared context and tool data;
|
||||
voice-card titles and pointer presentation translate those known labels at display
|
||||
time, while arbitrary names and callsigns remain unchanged.
|
||||
@@ -847,7 +869,9 @@ Voice controls bind to a protocol-independent session factory. The default WebRT
|
||||
|
||||
The standalone app offers OpenAI and Gemini Live through an explicit provider chooser. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
|
||||
|
||||
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 30 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields.
|
||||
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 33 tools using separate description-only metadata; a session is offered 32 of them unless `OVERPASS_UPSTREAMS` is set, which adds `osm_query` and its one prompt sentence (`server/providers/openai/tools.js`). Description customization cannot replace argument types, enum values or required fields.
|
||||
|
||||
Voice areas live in a store owned by the action runner for the page (`src/data/areaStore.js`); the model gets an `areaId`, a rounded bounding box and a summary, never geometry. `resolve_area` (`src/voice/areaActions.js`, `src/data/areaResolver.js`) checks the store, bundled Natural Earth regions, then bundled countries and states by name — a name several prominent units share returns up to four candidates unless the view is inside exactly one — and then the annotation outline ladder (`resolveAnnotationTarget`: bundled counties, Census places and neighborhoods, the guarded `/api/geocode/outline`, and Overpass only when configured). Administrative geometry is stored only when its source identity agrees with the requested unit, by name or by a form the bundled matcher reads as that unit at its level ("State of Texas", "Province of Bagmati", "USA", "UK"; not "Great Britain" or "Korea"); stale, mismatched, synthesized or otherwise unverified administrative boundaries are refused rather than relabelled. Clarification IDs use the bounded `ne:<kind>:<slug>` grammar and are accepted only from the current resolver's candidate memory. A lookup past 8 s answers `AREA_TIMEOUT` and keeps filling the caches. `around=true` asks the same ladder for the grounds enclosing a landmark (Nominatim, then the OpenFreeMap area holding it) and otherwise stores a 250 m buffer marked approximate. `analyst_query` scopes add `area`, `drawn` (newest draw-tool outline) and `annotation` (newest or named outline); region scopes use the same resolver within 3 s. Drawn outlines are a simplified copy (≤1,200 vertices); counting reads the stored geometry (`src/data/areaGeometry.js`). `find_imagery` (`src/voice/imageryActions.js`) sets the Recent Imagery box from an area, the view or the turn's pointer snapshot and pins the best acquisition; any hand change to the panel or explicit layer OFF while it searches ends the request (`DISPLACED`) without pinning or success narration. `osm_query` asks `/api/osm/features` for one curated preset in a bounded box; the route answers `OVERPASS_NOT_CONFIGURED` without an operator Overpass. Results go to the session-only `osm-places` layer and become the analyst's last answer; a list capped at its limit stays incomplete in follow-ups, which keep its "listed the first N" caveat and report counts as floors. An explicit OSM Places OFF while the provider is pending aborts the work and prevents re-enable, result, credit and follow-up-memory commits. The voice answer deadline also aborts the client provider request, so the caller does not leave a timed-out request without a cancellation owner. The server's whole search (22 s across every configured endpoint, with Overpass told 20 s) ends before that 25 s deadline, because a browser that stops waiting cancels the upstream query and leaves nothing cached for a retry.
|
||||
|
||||
`createGevActionRunner` routes `control_cctv` enable/disable through the shared
|
||||
layer visibility lifecycle. Results distinguish successful changes from
|
||||
@@ -915,10 +939,9 @@ voice disposes the runner.
|
||||
Analyst result memory is committed only after any Contacts-window projection
|
||||
finishes and the call is still current. Cancelled or superseded queries cannot
|
||||
return success or replace follow-up memory; Contacts follow-ups filter the same
|
||||
cohort that was shown, and partial/unanswered coverage remains attached when a
|
||||
remembered result is narrowed.
|
||||
The narrowed answer retains the remembered scope label and detail, including
|
||||
the Contacts-window attribution, unless the follow-up supplies a new scope.
|
||||
cohort that was shown and retain its displayed scope and loaded-data note unless
|
||||
the follow-up supplies a new scope. Partial/unanswered coverage remains attached
|
||||
when a remembered result is narrowed.
|
||||
|
||||
Application startup constructs fresh layer instances from explicit source objects.
|
||||
Standalone composition selects the existing providers. Controls, launch orbit
|
||||
@@ -1216,12 +1239,12 @@ addition to the locally accumulated trail, never a promise of complete coverage.
|
||||
Snapshot coverage, completeness and freshness are separate fields. A partially
|
||||
admitted aircraft snapshot retains absent contacts for up to five minutes before
|
||||
the usual missed-poll eviction. An OpenSky row with an ICAO24 address but null
|
||||
longitude and latitude is a contact without a position, not a malformed row: it
|
||||
leaves the snapshot complete and is listed in `retainIds`, so civil flights
|
||||
retain that aircraft under the same five-minute bound, unless it is likely
|
||||
landed, while other absent aircraft fade and leave on the missed-poll limits. A
|
||||
nonempty snapshot with no positioned rows is still malformed. Military readsb
|
||||
admission is unchanged. Unknown snapshot times remain unknown in stats. An invalid nonempty
|
||||
longitude and latitude, or a readsb row with a hex but no `lat`/`lon`, is a
|
||||
contact without a position, not a malformed row: it leaves the snapshot complete
|
||||
and is listed in `retainIds`, so civil and military flights retain that aircraft
|
||||
under the same five-minute bound, unless it is likely landed, while other absent
|
||||
aircraft fade and leave on the missed-poll limits. A nonempty snapshot with no
|
||||
positioned rows is still malformed. Unknown snapshot times remain unknown in stats. An invalid nonempty
|
||||
snapshot retains the previous display. Empty vessel refreshes retain the existing
|
||||
first-connect grace and warm-data behavior. Known source failures have bounded
|
||||
messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
|
||||
@@ -3111,9 +3134,10 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> take over under the Cockpit cap of 60. Loading/capped contacts remain 2D,
|
||||
> out-of-range contacts use rotation-free dots, and exit restores map treatment.
|
||||
> Its Radio popover exposes compact power,
|
||||
> transport, station, and volume controls. Cockpit Previous/Next preserves
|
||||
> selection, autoplay, and broadcaster fallback audio without starting the
|
||||
> map-view station flights that compete with the first-person camera. These
|
||||
> transport, station, and volume controls. Cockpit Previous/Next changes the
|
||||
> selection without starting the map-view station flights that compete with
|
||||
> the first-person camera; like the Context Previous/Next buttons, it plays
|
||||
> (with broadcaster fallback) only while a stream is already active. These
|
||||
> are a Cockpit-only accordion:
|
||||
> each static header uses the left accordion's label and divider with a dedicated
|
||||
> directional-chevron disclosure button and no full-row hover treatment. Only one
|
||||
@@ -3276,16 +3300,25 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> outlasts its retries, the proxy pauses upstream calls for the `Retry-After`
|
||||
> (60 seconds when none, at most 10 minutes). A request whose points are all
|
||||
> cached, fresh or stale, is still served; a request with any uncached point
|
||||
> gets 502 and the page uses its geoid fallback for that chunk. Each request
|
||||
> gets 502, whose `partial` lists the real heights it has (null for the rest)
|
||||
> when it has any, and the page keeps those and uses its geoid fallback only
|
||||
> for the points without one. The same holds when the upstream answers a
|
||||
> point with a null height. Each request
|
||||
> that was waiting on the upstream when the 429 came back logs one warning;
|
||||
> requests refused during the pause do not log. Client geoid
|
||||
> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the
|
||||
> first later successful fetch. Smaller chunks reduce timeout risk; complete
|
||||
> camera batches still wait for their sequential requests, and unresolved
|
||||
> placement continues to use the existing prior until real heights arrive.
|
||||
> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded
|
||||
> POSTs through the shared local-request gate, then limits requests to 300/min
|
||||
> per socket peer and 900/min globally before reading or validating bodies.
|
||||
> - **Overpass cache admission:** `/api/overpass` refuses cross-site POSTs
|
||||
> through the keyless same-site gate (reverse-proxy forwarding headers are
|
||||
> admitted, as for `/api/terrain/heights`), then limits requests to 300/min
|
||||
> per peer — IPv6 peers keyed by /64 — and 900/min across all remote peers
|
||||
> before reading or validating bodies. Direct loopback (no forwarding
|
||||
> headers) has its own 300/min window outside that global; a local reverse
|
||||
> proxy's requests carry forwarding headers and are counted as remote, keyed
|
||||
> `proxied:<socket peer>`, so they cannot spend the owner's window. Upstream
|
||||
> quota stays shared, so owner cache misses can still hit 429.
|
||||
> Tag-filter brackets are scanned once; nested or unmatched brackets return 400.
|
||||
> It then checks fresh memory, identical in-flight work, and fresh disk entries
|
||||
> before invoking its separate local 90/min upstream limiter. Cache and
|
||||
@@ -4009,31 +4042,33 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
|
||||
requests use Play; a qualified Play-shaped tool call is normalized to Select so
|
||||
its criteria cannot be silently ignored.
|
||||
|
||||
| Layer | Source | File | Proxy | Update Interval |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
||||
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
|
||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
||||
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
||||
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
||||
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
|
||||
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
|
||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||
| Layer | Source | File | Proxy | Update Interval |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
||||
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
|
||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
||||
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
||||
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
||||
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
|
||||
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
|
||||
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
|
||||
|
||||
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
|
||||
@@ -4062,7 +4097,7 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
|
||||
|
||||
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
||||
|
||||
Prohibited & Restricted Airspace is off by default (share-link token `6`) and sits in
|
||||
Prohibited & Restricted Airspace is off by default (share-link token `8`) and sits in
|
||||
Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA
|
||||
FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last
|
||||
good payload as stale when a refresh fails. Client-supplied URLs are rejected.
|
||||
@@ -4732,8 +4767,9 @@ 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; schema-valid responses with zero normalized stations count as failed
|
||||
queries rather than inflating refresh health. Each specialist query also needs
|
||||
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
|
||||
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
|
||||
@@ -4764,10 +4800,12 @@ accepted snapshot, and feed telemetry before re-initialization; monotonic
|
||||
ownership tokens plus the session boundary keep late callbacks from a retired
|
||||
session inert.
|
||||
A tuner drag resolves previews against the single accepted snapshot captured at
|
||||
pointer-down, even while a newer catalog is admitted. Release starts playback
|
||||
only when the current same-ID record still exactly matches the frozen
|
||||
presentation and stream metadata; removal or replacement reports the channel as
|
||||
unavailable and never silently retargets the drag. A cold degraded fallback may
|
||||
pointer-down, even while a newer catalog is admitted. Like Previous/Next,
|
||||
release starts playback only if a stream was active when the drag began;
|
||||
otherwise it just selects the station. It commits only when the current same-ID
|
||||
record still exactly matches the frozen presentation and stream metadata.
|
||||
Removal or replacement reports the channel as unavailable and never silently
|
||||
retargets the drag. A cold degraded fallback may
|
||||
still populate the directory and globe, but its null accepted generation cannot
|
||||
populate or begin the tuner.
|
||||
|
||||
@@ -4944,7 +4982,9 @@ before the later control succeeds, only its playback handoff is suppressed; its
|
||||
result reads enabled state from the manager and audio state from the Radio
|
||||
module. Voice Pause is a playback-only no-op while the layer is disabled; it
|
||||
never enables Radio and reports a fulfilled-false pause as failure. A
|
||||
newer-response or user-origin Stop still cancels stale handoff work.
|
||||
newer-response or user-origin Stop still cancels stale handoff work, as do a
|
||||
user Pause, a user Previous/Next that only selects another station, and a tuner
|
||||
release onto a station the directory removed or changed mid-drag.
|
||||
Only a newer user turn or session teardown aborts the complete active-tool set.
|
||||
A cancelled turn cannot publish a late Radio playback request. Dedicated Radio
|
||||
controls and generic layer-visibility commands both forward cancellation.
|
||||
@@ -4985,6 +5025,7 @@ silently demoting every later lookup for the session.
|
||||
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
|
||||
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
|
||||
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
|
||||
- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until its last finger or button lifts, even outside the canvas, unless the window loses focus or the page is hidden first; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m.
|
||||
- For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop.
|
||||
- Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff.
|
||||
- Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk.
|
||||
@@ -5433,8 +5474,11 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
### AI HUD Summary (June 2026)
|
||||
|
||||
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
|
||||
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
|
||||
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
|
||||
- Input: place/street/nearby-place labels, enabled layers and `feedProvenance`, with layer names as the user sees them; outside English the narration note and per-layer state ids are left out (`overall` carries the state). No model call without a place, street or nearby-place label: the deterministic line is shown.
|
||||
- Language: the HUD sends `locale` (part of the dedupe signature); the proxy keeps a shipped locale, English otherwise. Switching language in place drops the reply and aborts the request in flight, so the next tick asks again.
|
||||
- Reply: `{summary, feedState}` (`text.format` JSON schema, `feedState` a snapshot state or null). A view that is not nominal needs the matching `feedState`. The HUD adds its own translated tag, state first and layer names last (`STALE | … | LIVE FLIGHTS`), so the warning is never clipped. Outside English a reply falls back when it carries a feed-state id, a translated layer's English name or id, only Latin letters in a non-Latin language, or, with every feed healthy, the UNAVAILABLE word. `get_hud_caption` uses the same check and tag order, in English, and returns `feedState`.
|
||||
- The model is asked for at most five words; the proxy keeps up to 80 characters, or 20 for Japanese and Chinese, cut at a word boundary unless the first word is longer. It does not count words, since the model reads a name like Rue de Rivoli as one. Errors and the 5 s timeout fall back to the deterministic line; a camera move during typing shows that line instead of the fragment.
|
||||
- `partial` is a feed state of its own, ranked between `fallback` and `nominal`, so one partial layer, or partial plus nominal, reads partial, not off or nominal (HUD, voice view state, `analyst_query`, `brief`, the agent world packet). Its narration note says coverage is incomplete rather than not live.
|
||||
- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable.
|
||||
|
||||
### Place-search providers
|
||||
@@ -5719,7 +5763,7 @@ pins and share links already supply complete boxes.
|
||||
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
|
||||
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
|
||||
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
|
||||
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the first reveal within a bounded wall-clock window before the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
||||
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the settle frame and then the first reveal frame, each within a 500 ms wall-clock budget that sits inside the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. Only an inconclusive timeout is retried (once); a timeout keeps every angle it measured (the settle frame before a reveal timeout, and the late frame itself, which is read before the deadline check), so a measured wrong angle fails without retry. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
||||
|
||||
### TR-3B conversion Easter egg (August 2026)
|
||||
|
||||
@@ -6068,6 +6112,11 @@ easier to meet (detection is now on more often), but does not create it.
|
||||
TomTom draws only flow-tile lines with their own flow; roads without flow
|
||||
are not drawn and the status says so. A `full`-coverage flow line carries
|
||||
both travel directions, a `one_side` line the direction it is drawn in.
|
||||
When an opposite-direction `one_side` line runs within 5 m of at least two
|
||||
of three points a quarter, half and three quarters along a `one_side` line,
|
||||
the two are one street's directions and each
|
||||
gets a two-way direction's half share of dots, so the street is as dense as
|
||||
one `full` line (#1455).
|
||||
OSM draws OpenFreeMap roads with flow matched onto them (below). Hybrid
|
||||
draws eligible TomTom motor-road lines and adds OpenFreeMap roads only where no eligible TomTom line
|
||||
runs within 35 m in the same travel direction (30 degrees), tested every
|
||||
@@ -6327,6 +6376,46 @@ 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
|
||||
|
||||
@@ -20,7 +20,7 @@ The session adapter, action runner and backend interfaces remain unchanged.
|
||||
| `pointerContext.js` | Pointer position, dwell pick and the current turn's pointer snapshot (session-scoped) |
|
||||
| `referents.js` | Numbered items from the latest result for `referent:n` (session-scoped) |
|
||||
| `resultDisplay.js` | What the voice card shows for a result and which items it numbers (read by the card and `referents.js`) |
|
||||
| `gevActions.js` runner | Analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) |
|
||||
| `gevActions.js` runner | Area handles (page lifetime); analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) |
|
||||
|
||||
The backend is replaceable per deployment: `realtimeController.js` accepts it as a
|
||||
`backend` option and falls back to `createRealtimeBackend()`. Its two method
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
"src/app/mapLifecycle.test.mjs",
|
||||
"src/app/startupCamera.test.mjs",
|
||||
"src/app/viewer.js",
|
||||
"src/app/zoomOutGuard.test.mjs",
|
||||
"src/app/controls.test.mjs",
|
||||
"scripts/qa-zoom-out-guard.mjs",
|
||||
"src/app/atmosphereCompat.test.mjs",
|
||||
"src/standalone/application.js",
|
||||
"src/standalone/controls.js",
|
||||
@@ -1113,6 +1116,7 @@
|
||||
"scripts/qa-anime-sky.mjs",
|
||||
"scripts/qa-sky-model-lighting.mjs",
|
||||
"server/providers/overpass/requestAdmission.test.mjs",
|
||||
"src/tooling/trackArrivalObservation.mjs",
|
||||
"src/tooling/trackArrivalObservation.test.mjs",
|
||||
"src/ui/styles/status-badge.css",
|
||||
"src/ui/cctvBadges.test.mjs",
|
||||
@@ -1122,6 +1126,9 @@
|
||||
"src/voice/globeResetConfirmation.test.mjs",
|
||||
"src/hudDms.test.mjs",
|
||||
"src/hudSummaryGate.test.mjs",
|
||||
"src/hudSummaryLine.test.mjs",
|
||||
"src/testSupport/stubMgrs.mjs",
|
||||
"src/tools/queries/hud-caption-feed-state.test.mjs",
|
||||
"src/voice/hudPlaceLabels.test.mjs",
|
||||
"src/voice/panel/preferences.test.mjs",
|
||||
"src/voice/panel/preferencesView.test.mjs",
|
||||
@@ -1188,6 +1195,9 @@
|
||||
"src/overlays/worldOverlayActionsView.test.mjs",
|
||||
"src/ui/stores/awarenessDirection.js",
|
||||
"src/layers/awareness/directionOverlay.test.mjs",
|
||||
"src/awarenessEcho.js",
|
||||
"src/awarenessEcho.test.mjs",
|
||||
"src/data/detectionAwarenessEcho.test.mjs",
|
||||
"src/hudReadouts.js",
|
||||
"src/ui/stores/hud.js",
|
||||
"src/ui/views/intelHudView.test.mjs",
|
||||
@@ -1214,5 +1224,19 @@
|
||||
"src/standalone/layerSources.test.mjs",
|
||||
"src/tooling/nominatimGuardLifecycle.test.mjs",
|
||||
"src/tooling/nominatimLockOwnership.test.mjs",
|
||||
"src/tooling/nominatimOutlineRoute.test.mjs"
|
||||
"src/tooling/nominatimOutlineRoute.test.mjs",
|
||||
"server/providers/common/rateLimitKeys.test.mjs",
|
||||
"src/voice/areaCandidateContract.js",
|
||||
"src/voice/layerActionOwnership.js",
|
||||
"src/data/areaGeometry.test.mjs",
|
||||
"src/data/areaResolver.test.mjs",
|
||||
"src/sources/osmFeatures.test.mjs",
|
||||
"src/layers/osmPlaces/index.test.mjs",
|
||||
"src/tooling/boundedCache.test.mjs",
|
||||
"src/tooling/osmFeaturesRoute.test.mjs",
|
||||
"src/voice/areaActions.test.mjs",
|
||||
"src/voice/imageryActions.test.mjs",
|
||||
"src/voice/osmActions.test.mjs",
|
||||
"src/voice/realtimeProtocol.test.mjs",
|
||||
"src/layers/awareness/rangeRing.test.mjs"
|
||||
]
|
||||
@@ -416,7 +416,11 @@
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/sources/overpass.js",
|
||||
"src/sources/rateLimit.js"
|
||||
"src/sources/rateLimit.js",
|
||||
"server/providers/common/boundedCache.js",
|
||||
"src/data/osmPresets.js",
|
||||
"server/providers/overpass/osmFeatures.js",
|
||||
"server/providers/overpass/osmFeaturesRoute.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -485,6 +489,7 @@
|
||||
"exports": ["./server/providers/openai"],
|
||||
"modules": [
|
||||
"src/i18n/marker.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/hudLayoutPolicy.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
"server/providers/common/request.js",
|
||||
@@ -507,9 +512,11 @@
|
||||
"server/providers/common/same-site.js",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/voice/actionSchemas.js",
|
||||
"src/voice/areaCandidateContract.js",
|
||||
"src/voice/layerManifest.js",
|
||||
"server/providers/openai/toolDescriptions.js",
|
||||
"src/sources/rateLimit.js"
|
||||
"src/sources/rateLimit.js",
|
||||
"server/providers/overpass/constants.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -1677,6 +1684,7 @@
|
||||
"src/data/distanceFormat.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/awarenessEcho.js",
|
||||
"src/cockpitMath.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
@@ -2227,6 +2235,7 @@
|
||||
"src/ui/placeNames.js",
|
||||
"src/cyberSonarScene.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/awarenessEcho.js",
|
||||
"src/hudLayouts.js",
|
||||
"src/hudLayoutPolicy.js",
|
||||
"src/ui/radioPanelPlacement.js",
|
||||
@@ -2273,6 +2282,7 @@
|
||||
"src/app/layers/traffic.js",
|
||||
"src/app/operations.js",
|
||||
"src/app/scene.js",
|
||||
"src/app/zoomOutGuard.js",
|
||||
"src/sources/sourceSlot.js",
|
||||
"src/app/sources.js",
|
||||
"src/app/startupChrome.js",
|
||||
@@ -2352,6 +2362,7 @@
|
||||
"src/data/groundSnap.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/infrastructure.js",
|
||||
"src/data/osmOverlays.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/labelArbiter.js",
|
||||
"src/data/idSwitches.js",
|
||||
@@ -3187,7 +3198,20 @@
|
||||
"src/data/placeBoundaries.js",
|
||||
"src/data/placeNames.js",
|
||||
"src/data/local_data/us_census_places/files.js",
|
||||
"src/data/local_data/wof_neighborhoods/files.js"
|
||||
"src/data/local_data/wof_neighborhoods/files.js",
|
||||
"src/sources/osmFeatures.js",
|
||||
"src/voice/imageryActions.js",
|
||||
"src/voice/layerActionOwnership.js",
|
||||
"src/data/areaGeometry.js",
|
||||
"src/layers/osmPlaces/model.js",
|
||||
"src/data/areaStore.js",
|
||||
"src/data/areaResolver.js",
|
||||
"src/voice/areaCandidateContract.js",
|
||||
"src/data/osmPresets.js",
|
||||
"src/voice/osmActions.js",
|
||||
"src/layers/osmPlaces/index.js",
|
||||
"src/app/layers/osmPlaces.js",
|
||||
"src/voice/areaActions.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3216,6 +3240,7 @@
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/awarenessEcho.js",
|
||||
"src/data/approxDistance.js",
|
||||
"src/data/bhoteKoshiEvent.js",
|
||||
"src/ui/imagerySplit.js",
|
||||
@@ -3287,6 +3312,7 @@
|
||||
"src/data/geoid.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/infrastructure.js",
|
||||
"src/data/osmOverlays.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/satellitePass.js",
|
||||
"src/data/labelArbiter.js",
|
||||
@@ -3799,7 +3825,10 @@
|
||||
"src/data/placeBoundaries.js",
|
||||
"src/data/placeNames.js",
|
||||
"src/data/local_data/us_census_places/files.js",
|
||||
"src/data/local_data/wof_neighborhoods/files.js"
|
||||
"src/data/local_data/wof_neighborhoods/files.js",
|
||||
"src/layers/osmPlaces/model.js",
|
||||
"src/layers/osmPlaces/index.js",
|
||||
"src/app/layers/osmPlaces.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -4164,6 +4193,7 @@
|
||||
"exports": ["./voice/action-schemas"],
|
||||
"modules": [
|
||||
"src/voice/actionSchemas.js",
|
||||
"src/voice/areaCandidateContract.js",
|
||||
"src/voice/layerManifest.js",
|
||||
"src/hudLayoutPolicy.js"
|
||||
],
|
||||
|
||||
+20
-3
@@ -3073,20 +3073,27 @@ async function main() {
|
||||
expectedSlot: targetIndex,
|
||||
previewPlayDelta: previewPlays - playsBefore,
|
||||
commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore,
|
||||
audioState: state.audioState,
|
||||
tuningStatic: state.tuningStatic,
|
||||
samples,
|
||||
spread: Math.max(...samples) - Math.min(...samples),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'pointer preview never plays and release commits the nearest station once without a needle sweep',
|
||||
'from a stopped Radio, pointer release only selects the nearest station, silently and without a needle sweep',
|
||||
tunerDirectRelease.targetId
|
||||
&& tunerDirectRelease.selectedId === tunerDirectRelease.targetId
|
||||
&& tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot
|
||||
&& tunerDirectRelease.previewPlayDelta === 0
|
||||
&& tunerDirectRelease.commitPlayDelta === 1
|
||||
&& tunerDirectRelease.commitPlayDelta === 0
|
||||
&& tunerDirectRelease.audioState === 'stopped'
|
||||
&& !tunerDirectRelease.tuningStatic
|
||||
&& tunerDirectRelease.spread < 0.5,
|
||||
JSON.stringify(tunerDirectRelease),
|
||||
);
|
||||
// The release plays only if a stream was active when the drag began.
|
||||
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
|
||||
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||
// Earlier cases scroll and resize the directory. Bring the physical drag
|
||||
// target back into its scroller before deriving viewport mouse coordinates.
|
||||
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
|
||||
@@ -3494,6 +3501,9 @@ async function main() {
|
||||
});
|
||||
});
|
||||
await sleep(500);
|
||||
// Next left the Radio stopped; a release plays only from an active stream.
|
||||
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
|
||||
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||
const failedTuner = await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const slider = document.getElementById('radio-tuner-slider');
|
||||
@@ -3692,7 +3702,14 @@ async function main() {
|
||||
radio.stopPlayback();
|
||||
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
|
||||
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
|
||||
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
|
||||
// Next plays only while a stream is active, so start from a playing station.
|
||||
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: true, focus: false });
|
||||
});
|
||||
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||
await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const camera = gev.viewer.camera;
|
||||
const radio = gev.dataManager.layers.get('radio').module;
|
||||
const pool = gev.styleManager._radioControls._radioTunerPool;
|
||||
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
|
||||
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
* node scripts/qa-voice-routing.mjs --layer behavior --deixis-only --headful --shots qa-shots/point-and-ask
|
||||
* node scripts/qa-voice-routing.mjs --layer deixis --budget 12 # pointer_context routing (model turns)
|
||||
* node scripts/qa-voice-routing.mjs --layer deixis-live --url ... # in-app text turns (model turns)
|
||||
* node scripts/qa-voice-routing.mjs --layer behavior --behavior geometry # areas, drawn counts, imagery
|
||||
*
|
||||
* House gotchas honored: camera.cancelFlight() before every teleport; puppeteer
|
||||
* suites must run sequentially with other harnesses (SwiftShader saturation);
|
||||
@@ -75,7 +76,7 @@ const LAYER = getOpt('--layer', 'all'); // routing | behavior | all | deixis | d
|
||||
const TURN_BUDGET = Number(getOpt('--budget', '120'));
|
||||
const PHRASES_PER_SESSION = Number(getOpt('--batch', '6'));
|
||||
const ONLY = getOpt('--only', null); // substring filter on phrase text
|
||||
const BEHAVIOR = getOpt('--behavior', 'all'); // all | outlines | acceptance
|
||||
const BEHAVIOR = getOpt('--behavior', 'all'); // all | outlines | acceptance | geometry
|
||||
const DEIXIS_ONLY = process.argv.includes('--deixis-only'); // behavior: point-and-ask block only
|
||||
const HEADFUL = process.argv.includes('--headful'); // real GPU, visible window
|
||||
const SHOT_DIR = getOpt('--shots', null); // point-and-ask evidence screenshots
|
||||
@@ -164,7 +165,11 @@ async function mintSession() {
|
||||
throw new Error(
|
||||
`token mint returned no client secret: ${JSON.stringify(body).slice(0, 160)}`,
|
||||
);
|
||||
return { value, model };
|
||||
// The tools this server offered (osm_query only with an operator Overpass).
|
||||
const tools = new Set(
|
||||
(body?.session?.tools || []).map((tool) => tool?.name).filter(Boolean),
|
||||
);
|
||||
return { value, model, tools };
|
||||
}
|
||||
|
||||
/** One Realtime WS session that can run several text turns sequentially. */
|
||||
@@ -371,8 +376,10 @@ async function runRoutingLayer() {
|
||||
}
|
||||
const batch = list.slice(i, i + PHRASES_PER_SESSION);
|
||||
let session;
|
||||
let offered = new Set();
|
||||
try {
|
||||
const { value, model } = await mintSession();
|
||||
const { value, model, tools } = await mintSession();
|
||||
offered = tools;
|
||||
session = new RoutingSession(value, model, evidence);
|
||||
await session.connect();
|
||||
} catch (e) {
|
||||
@@ -390,6 +397,14 @@ async function runRoutingLayer() {
|
||||
skipped(`route: "${p.phrase}"`, 'turn budget exhausted');
|
||||
continue;
|
||||
}
|
||||
const missing = (p.requires || []).filter((name) => !offered.has(name));
|
||||
if (missing.length) {
|
||||
skipped(
|
||||
`route: "${p.phrase}"`,
|
||||
`${missing.join(', ')} not offered (no configured Overpass)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
turnsUsed += 1;
|
||||
let calls = [];
|
||||
try {
|
||||
@@ -1361,7 +1376,11 @@ async function runBehaviorLayer() {
|
||||
// The point-and-ask block skips the first-run launcher so its evidence
|
||||
// screenshots show the map; the full run keeps the historical URL.
|
||||
const behaviorUrl = new URL(APP_URL);
|
||||
if (DEIXIS_ONLY || BEHAVIOR === 'acceptance')
|
||||
if (
|
||||
DEIXIS_ONLY ||
|
||||
BEHAVIOR === 'acceptance' ||
|
||||
BEHAVIOR === 'geometry'
|
||||
)
|
||||
behaviorUrl.searchParams.set('welcome', '0');
|
||||
await page.goto(behaviorUrl.href, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
@@ -1420,6 +1439,10 @@ async function runBehaviorLayer() {
|
||||
await runOutlineAcceptance({ page, run, settle, overpassRequests });
|
||||
return;
|
||||
}
|
||||
if (BEHAVIOR === 'geometry') {
|
||||
await runGeometryScenarios({ page, run, settle, overpassRequests });
|
||||
return;
|
||||
}
|
||||
|
||||
// (1) fly_to_location lands near the target. Annotations follow IMMEDIATELY
|
||||
// while the camera is local — the resolver's proximity gate rejects far
|
||||
@@ -2340,6 +2363,8 @@ async function runBehaviorLayer() {
|
||||
resolverLog,
|
||||
packTimes,
|
||||
});
|
||||
// Voice geometry last: it moves the camera and turns layers off.
|
||||
await runGeometryScenarios({ page, run, settle, overpassRequests });
|
||||
|
||||
const shotDir = path.join(ROOT, 'qa-shots');
|
||||
fs.mkdirSync(shotDir, { recursive: true });
|
||||
@@ -2483,6 +2508,193 @@ const OUTLINE_ASKS = [
|
||||
{ entityKind: 'building', latitude: 30.27472, longitude: -97.74035 }, 'building'],
|
||||
].map(([group, target, lat, lon, height, extra = {}, shot = null]) => ({ group, target, lat, lon, height, extra, shot }));
|
||||
|
||||
/**
|
||||
* Voice geometry, runner-driven: reusable areas, drawn-area counts, a
|
||||
* landmark area and recent imagery, all without a default Overpass request.
|
||||
* Public Nominatim use is read from the server's daily count.
|
||||
*/
|
||||
async function runGeometryScenarios({ page, run, settle, overpassRequests }) {
|
||||
const shotDir = path.join(ROOT, 'qa-shots', 'pr-geometry');
|
||||
fs.mkdirSync(shotDir, { recursive: true });
|
||||
const shot = (name) => page.screenshot({ path: path.join(shotDir, `${name}.png`) }).catch(() => {});
|
||||
const nominatimStart = nominatimCount();
|
||||
const overpassStart = overpassRequests.length;
|
||||
const teleport = (lat, lon, height, pitch = -90) => page.evaluate(({ lat, lon, height, pitch }) => {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (lon * Math.PI) / 180, latitude: (lat * Math.PI) / 180, height,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: (pitch * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
}, { lat, lon, height, pitch });
|
||||
const timings = {};
|
||||
|
||||
// (G1) "Outline Bagmati, then count fires inside it".
|
||||
await teleport(27.7, 85.4, 400_000);
|
||||
await settle(1500);
|
||||
let t0 = Date.now();
|
||||
const bagmati = await run('resolve_area', { query: 'Bagmati province', draw: true, flyTo: false });
|
||||
timings.bagmatiMs = Date.now() - t0;
|
||||
const bagmatiRefused = [
|
||||
'AREA_IDENTITY_MISMATCH',
|
||||
'AREA_IDENTITY_UNVERIFIED',
|
||||
].includes(bagmati?.code);
|
||||
const bagmatiVerified =
|
||||
bagmati?.ok === true &&
|
||||
bagmati?.drawn === true &&
|
||||
bagmati?.level === 'admin1' &&
|
||||
bagmati?.name === 'Bagmati Province' &&
|
||||
bagmati?.sourceIdentity === 'Bagmati Province';
|
||||
report(
|
||||
bagmatiVerified || bagmatiRefused,
|
||||
'geometry: "outline Bagmati province" uses verified current identity or refuses honestly',
|
||||
`areaId=${bagmati?.areaId} name=${bagmati?.name} sourceIdentity=${bagmati?.sourceIdentity} source=${bagmati?.source} km2=${bagmati?.areaKm2} ms=${timings.bagmatiMs} code=${bagmati?.code || ''} ${bagmati?.error || ''}`,
|
||||
);
|
||||
await settle(3000);
|
||||
await shot('bagmati-outline');
|
||||
t0 = Date.now();
|
||||
let r = await run('resolve_area', { query: 'Bagmati province' });
|
||||
report(
|
||||
bagmatiVerified
|
||||
? r?.ok === true &&
|
||||
r?.cached === true &&
|
||||
r?.ms < 300 &&
|
||||
r?.sourceIdentity === 'Bagmati Province'
|
||||
: bagmatiRefused &&
|
||||
r?.ok === false &&
|
||||
r?.code === bagmati?.code &&
|
||||
!r?.areaId,
|
||||
'geometry: a repeated Bagmati request reuses only verified identity, never refused geometry',
|
||||
`ok=${r?.ok} code=${r?.code || ''} areaId=${r?.areaId || ''} sourceIdentity=${r?.sourceIdentity || ''} cached=${r?.cached} ms=${r?.ms} roundTrip=${Date.now() - t0}`,
|
||||
);
|
||||
// A city no bundled pack holds: the guarded Nominatim outline route.
|
||||
t0 = Date.now();
|
||||
const kathmandu = await run('resolve_area', { query: 'Kathmandu', draw: true, flyTo: false });
|
||||
timings.kathmanduMs = Date.now() - t0;
|
||||
report(kathmandu?.ok === true && kathmandu?.drawn === true && kathmandu?.source === 'osm' && kathmandu?.areaKm2 > 10 && kathmandu?.areaKm2 < 2000,
|
||||
'geometry: "outline Kathmandu" comes from the guarded Nominatim outline',
|
||||
`areaId=${kathmandu?.areaId} source=${kathmandu?.source} km2=${kathmandu?.areaKm2} parts=${kathmandu?.parts} ms=${timings.kathmanduMs} err=${kathmandu?.code || ''} ${kathmandu?.error || ''}`);
|
||||
await settle(2500);
|
||||
await shot('kathmandu-outline');
|
||||
r = await run('resolve_area', { query: 'Georgia' });
|
||||
report(r?.ok === false && r?.needsClarification === true && (r?.candidates || []).length >= 2,
|
||||
'geometry: an ambiguous area asks with candidates instead of guessing',
|
||||
`candidates=${(r?.candidates || []).map((c) => `${c.name}/${c.level}/${c.country || ''}`).join(', ')}`,
|
||||
);
|
||||
if (bagmatiVerified) {
|
||||
await run('set_layer_visibility', {
|
||||
layerId: 'local-firms',
|
||||
enabled: true,
|
||||
});
|
||||
let fires = null;
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
fires = await run('analyst_query', {
|
||||
layers: ['local-firms'],
|
||||
scope: { kind: 'area', areaId: bagmati.areaId },
|
||||
});
|
||||
if (fires?.ok || !['NOT_READY', 'LAYER_OFF'].includes(fires?.code)) break;
|
||||
await settle(4000);
|
||||
}
|
||||
if (fires?.code === 'FEED_UNAVAILABLE') {
|
||||
skipped(
|
||||
'geometry: "count fires inside it" counts inside the verified Bagmati area',
|
||||
`fires feed unavailable: ${fires?.error}`,
|
||||
);
|
||||
} else {
|
||||
report(
|
||||
fires?.ok === true &&
|
||||
Number.isFinite(fires?.count) &&
|
||||
/Bagmati/i.test(fires?.scopeLabel || '') &&
|
||||
fires?.coverage?.scope === `area:${bagmati.areaId}`,
|
||||
'geometry: "count fires inside it" counts inside the verified Bagmati area',
|
||||
`count=${fires?.count} scopeLabel="${fires?.scopeLabel}" scope=${fires?.coverage?.scope} err=${fires?.error || ''}`,
|
||||
);
|
||||
}
|
||||
await run('set_layer_visibility', {
|
||||
layerId: 'local-firms',
|
||||
enabled: false,
|
||||
});
|
||||
} else {
|
||||
skipped(
|
||||
'geometry: fire count waits for a verified Bagmati area',
|
||||
`boundary refused with ${bagmati?.code || 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
// (G2) "Count flights inside the area I drew": the draw tool's own spec.
|
||||
await teleport(30.27, -97.74, 600_000);
|
||||
await run('set_layer_visibility', { layerId: 'flights', enabled: true });
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'anywhere' } });
|
||||
if (r?.ok && r.count > 0) break;
|
||||
await settle(5000);
|
||||
}
|
||||
const drawnBox = [[-99, 29], [-96.5, 29], [-96.5, 31.5], [-99, 31.5], [-99, 29]];
|
||||
await page.evaluate((ring) => window.__gevAnnotations.annotate([{ type: 'area', manual: true, ring, label: 'QA drawn box' }]), drawnBox);
|
||||
r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'drawn' } });
|
||||
const loaded = await run('analyst_query', { layers: ['flights'], scope: { kind: 'anywhere' } });
|
||||
report(r?.ok === true && r?.scopeLabel === 'inside QA drawn box' && Number.isFinite(r?.count) && r.count <= (loaded?.count ?? Infinity),
|
||||
'geometry: "count flights inside the area I drew" counts inside the drawn outline',
|
||||
`count=${r?.count} of ${loaded?.count} loaded scopeLabel="${r?.scopeLabel}" err=${r?.error || ''}`);
|
||||
await settle(2500);
|
||||
await shot('drawn-area-count');
|
||||
// Thousands of aircraft starve a SwiftShader page; the landmark lookup
|
||||
// below has a 10 s budget.
|
||||
await run('set_layer_visibility', { layerId: 'flights', enabled: false });
|
||||
await run('clear_annotations', {});
|
||||
|
||||
// (G3) "Draw an area around the Ferry Building": the enclosing mapped area
|
||||
// (Nominatim grounds, then the OpenFreeMap area holding it), else an
|
||||
// approximate buffer — either way a drawn, reusable area.
|
||||
await teleport(37.7955, -122.3937, 2500, -60);
|
||||
await settle(1500);
|
||||
t0 = Date.now();
|
||||
const ferry = await run('resolve_area', { query: 'Ferry Building, San Francisco', around: true, flyTo: false });
|
||||
timings.ferryMs = Date.now() - t0;
|
||||
const ferryNear = Array.isArray(ferry?.bbox) && Math.abs(ferry.bbox[1] - 37.795) < 0.05 && Math.abs(ferry.bbox[0] + 122.394) < 0.05;
|
||||
report(ferry?.ok === true && ferry?.drawn === true && ferryNear && (ferry?.approximate === true || ['osm', 'openfreemap'].includes(ferry?.source)),
|
||||
'geometry: "draw an area around the Ferry Building" draws a reusable landmark area',
|
||||
`areaId=${ferry?.areaId} source=${ferry?.source} approximate=${ferry?.approximate} basis=${ferry?.display?.basis} km2=${ferry?.areaKm2} ms=${timings.ferryMs} err=${ferry?.error || ''}`);
|
||||
await settle(3000);
|
||||
await shot('ferry-building-area');
|
||||
|
||||
// (G4) "Find recent imagery of this area": the Recent Imagery catalog for
|
||||
// that area, the best acquisition pinned through the layer itself.
|
||||
t0 = Date.now();
|
||||
r = await run('find_imagery', { areaId: ferry?.areaId || 'missing' });
|
||||
timings.imageryMs = Date.now() - t0;
|
||||
const pinned = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('recent-imagery')?.module?.getSnapshot?.().pins?.a?.key || null);
|
||||
if (r?.code === 'CATALOG_UNAVAILABLE' || r?.code === 'SEARCH_TIMEOUT') {
|
||||
skipped('geometry: "find recent imagery of this area" pins the best acquisition', `catalog: ${r?.error}`);
|
||||
} else {
|
||||
report(r?.ok === true && (r?.count === 0 ? r?.shown === null : Boolean(r?.shown?.date) && String(pinned).endsWith(`:${r.shown.date}`)),
|
||||
'geometry: "find recent imagery of this area" pins the best acquisition',
|
||||
`count=${r?.count} shown=${JSON.stringify(r?.shown)} pinned=${pinned} ms=${timings.imageryMs} err=${r?.error || ''}`);
|
||||
}
|
||||
await settle(4000);
|
||||
await shot('recent-imagery-area');
|
||||
await run('set_layer_visibility', { layerId: 'recent-imagery', enabled: false });
|
||||
|
||||
// (G5) Bulk place search without an operator Overpass: a plain refusal.
|
||||
const configured = await page.evaluate(() => fetch('/api/overpass/status').then((res) => res.json()).catch(() => null));
|
||||
r = await run('osm_query', { what: 'hospitals' });
|
||||
if (configured?.configured) {
|
||||
skipped('geometry: place search without an operator Overpass is refused', 'this server has OVERPASS_UPSTREAMS');
|
||||
} else {
|
||||
report(r?.ok === false && r?.code === 'OVERPASS_NOT_CONFIGURED',
|
||||
'geometry: place search without an operator Overpass is refused', `code=${r?.code} error=${r?.error}`);
|
||||
}
|
||||
|
||||
const nominatimUsed = nominatimCount() - nominatimStart;
|
||||
const overpass = overpassRequests.slice(overpassStart);
|
||||
console.log(` geometry timings: ${JSON.stringify(timings)}; public Nominatim requests: ${nominatimUsed}`);
|
||||
report(overpass.length === 0, 'geometry: no Overpass request', overpass.slice(0, 3).join(' | ') || 'none');
|
||||
report(nominatimUsed <= 15, 'geometry: public Nominatim requests within budget (15)', `used=${nominatimUsed}`);
|
||||
await run('clear_annotations', {});
|
||||
}
|
||||
|
||||
const NOMINATIM_STATE = path.join(ROOT, '.gev-cache', 'nominatim', 'state.json');
|
||||
function nominatimCount() {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env node
|
||||
/** Real-app zoom and tilt regression. Start a dev server, then run this file. */
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const origin = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: [
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--enable-unsafe-swiftshader',
|
||||
]),
|
||||
],
|
||||
protocolTimeout: 120_000,
|
||||
});
|
||||
const errors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.setViewport({ width: 1200, height: 800 });
|
||||
await page.goto(`${origin}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.viewer &&
|
||||
window.__godsEyeView.viewer.scene.screenSpaceCameraController
|
||||
.enableInputs &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
const renderer = await page.evaluate(async () => {
|
||||
const C = await import('/node_modules/cesium/Build/Cesium/index.js');
|
||||
const { maxPitchForHeight } = await import('/src/app/zoomOutGuard.js');
|
||||
const { readCameraTargetFrame } =
|
||||
await import('/src/ui/cameraOrientationControls.js');
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.trackedEntity = undefined;
|
||||
// Explicit real renders make each gesture independent of host frame rate.
|
||||
viewer.useDefaultRenderLoop = false;
|
||||
const controller = viewer.scene.screenSpaceCameraController;
|
||||
controller.inertiaZoom = 0;
|
||||
controller.inertiaSpin = 0;
|
||||
const original = viewer.camera.setView.bind(viewer.camera);
|
||||
const qa = {
|
||||
viewer,
|
||||
guardWrites: 0,
|
||||
targetPitch: () => readCameraTargetFrame(viewer)?.pitch,
|
||||
history: [],
|
||||
frame() {
|
||||
viewer.scene.requestRender();
|
||||
viewer.render();
|
||||
qa.history.push({
|
||||
height: viewer.camera._positionCartographic.height,
|
||||
guardWrites: qa.guardWrites,
|
||||
position: { ...viewer.camera.position },
|
||||
});
|
||||
if (qa.history.length > 80) qa.history.shift();
|
||||
},
|
||||
pose() {
|
||||
return {
|
||||
height: viewer.camera.positionCartographic.height,
|
||||
pitch: viewer.camera.pitch,
|
||||
maxPitch: maxPitchForHeight(
|
||||
viewer.camera.positionCartographic.height,
|
||||
),
|
||||
guardWrites: qa.guardWrites,
|
||||
};
|
||||
},
|
||||
reset(height = 1_000_000, pitch = -35) {
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.lookAtTransform(C.Matrix4.IDENTITY);
|
||||
original({
|
||||
destination: C.Cartesian3.fromDegrees(0, 0, height),
|
||||
orientation: { heading: 0, pitch: C.Math.toRadians(pitch), roll: 0 },
|
||||
});
|
||||
qa.frame();
|
||||
qa.guardWrites = 0;
|
||||
},
|
||||
};
|
||||
viewer.camera.setView = (options) => {
|
||||
// Cesium itself also calls setView while processing touch movement.
|
||||
if (new Error().stack.includes('/src/app/zoomOutGuard.js'))
|
||||
qa.guardWrites++;
|
||||
return original(options);
|
||||
};
|
||||
window.__zoomGuardQa = qa;
|
||||
const gl = viewer.scene.context._gl;
|
||||
const debug = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return gl.getParameter(debug ? debug.UNMASKED_RENDERER_WEBGL : gl.RENDERER);
|
||||
});
|
||||
console.log(`[INFO] WebGL renderer: ${renderer}`);
|
||||
const frame = (count = 1) =>
|
||||
page.evaluate(async (n) => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
window.__zoomGuardQa.frame();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
return window.__zoomGuardQa.pose();
|
||||
}, count);
|
||||
const reset = (height, pitch) =>
|
||||
page.evaluate((h, p) => window.__zoomGuardQa.reset(h, p), height, pitch);
|
||||
const session = await page.createCDPSession();
|
||||
for (const viewport of [
|
||||
{ width: 1200, height: 800 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
await page.setViewport(viewport);
|
||||
await page.evaluate(() => window.__zoomGuardQa.viewer.resize());
|
||||
const x = Math.round(viewport.width / 2);
|
||||
const y = Math.round(viewport.height / 2);
|
||||
await reset(1_000_000, -25);
|
||||
await page.mouse.move(x, y);
|
||||
await page.evaluate(
|
||||
({ x, y }) => {
|
||||
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: 2400,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ x, y },
|
||||
);
|
||||
const first = await frame();
|
||||
console.log(`[INFO] ${viewport.width}px wheel delivered`);
|
||||
const settled = await frame(40);
|
||||
assert.ok(first.height > 1_000_000, 'wheel must move the real camera');
|
||||
assert.ok(settled.guardWrites > 0, 'zoom guard must run');
|
||||
assert.ok(
|
||||
settled.pitch <= settled.maxPitch + 1e-6,
|
||||
`stationary pitch must finish easing: ${JSON.stringify(settled)}`,
|
||||
);
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await page.evaluate(
|
||||
({ x, y }) => {
|
||||
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: 2400,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ x, y },
|
||||
);
|
||||
await frame();
|
||||
}
|
||||
const ceiling = await frame(40);
|
||||
assert.ok(ceiling.height <= 40_000_001, 'user zoom respects the ceiling');
|
||||
assert.ok(
|
||||
ceiling.height > 39_000_000,
|
||||
'exercise the ceiling, not a low orbit',
|
||||
);
|
||||
assert.ok(
|
||||
ceiling.pitch <= ceiling.maxPitch + 1e-6,
|
||||
`ceiling must settle: ${JSON.stringify(await page.evaluate(() => window.__zoomGuardQa.history))}`,
|
||||
);
|
||||
console.log(
|
||||
`[PASS] ${viewport.width}px wheel, stationary easing and ceiling`,
|
||||
);
|
||||
if (process.env.QA_SCREENSHOT_DIR) {
|
||||
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(
|
||||
process.env.QA_SCREENSHOT_DIR,
|
||||
`zoom-ceiling-${viewport.width}.png`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
await reset();
|
||||
await page.mouse.move(x, y);
|
||||
await page.keyboard.down('Control');
|
||||
await page.mouse.down({ button: 'right' });
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
await page.mouse.move(x, y + i * 3);
|
||||
await frame();
|
||||
}
|
||||
await page.mouse.up({ button: 'right' });
|
||||
await page.keyboard.up('Control');
|
||||
const tilted = await frame(3);
|
||||
assert.equal(
|
||||
tilted.guardWrites,
|
||||
0,
|
||||
'Ctrl+right tilt must not trigger guard',
|
||||
);
|
||||
assert.ok(Math.abs(tilted.height - 1_000_000) > 1, 'tilt must move camera');
|
||||
|
||||
await reset();
|
||||
const touches = (dy) => [
|
||||
{ x: x - 60, y: y + dy, id: 1 },
|
||||
{ x: x + 60, y: y + dy, id: 2 },
|
||||
];
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: touches(0),
|
||||
});
|
||||
await frame();
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: touches(i * 3),
|
||||
});
|
||||
await frame();
|
||||
}
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchEnd',
|
||||
touchPoints: [],
|
||||
});
|
||||
const translated = await frame(3);
|
||||
assert.equal(
|
||||
translated.guardWrites,
|
||||
0,
|
||||
'parallel two-finger tilt must not trigger guard',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(translated.height - 1_000_000) > 1,
|
||||
'touch tilt must move camera',
|
||||
);
|
||||
console.log(
|
||||
`[PASS] ${viewport.width}px Ctrl+right and parallel touch tilt`,
|
||||
);
|
||||
await reset(1_000_000, -35);
|
||||
await page.evaluate(
|
||||
({ x, y }) => {
|
||||
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: 120,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ x, y },
|
||||
);
|
||||
const beforeTilt = await frame();
|
||||
assert.ok(
|
||||
beforeTilt.guardWrites > 0,
|
||||
'wheel correction must be pending before UI tilt',
|
||||
);
|
||||
const targetPitchBeforeClick = await page.evaluate(() =>
|
||||
window.__zoomGuardQa.targetPitch(),
|
||||
);
|
||||
assert.ok(
|
||||
Number.isFinite(targetPitchBeforeClick),
|
||||
'UI tilt fixture must have a ground target under the canvas center',
|
||||
);
|
||||
// Programmatic fixture resets precede moveEnd, so the button's published
|
||||
// aria state may still describe the previous view. The action toggles the
|
||||
// current target frame: angles shallower than -60 degrees are tilted.
|
||||
const wasTilted = targetPitchBeforeClick > -Math.PI / 3;
|
||||
await page.click('#tilt-map-view');
|
||||
const uiTilt = await page.evaluate(
|
||||
async (desiredDegrees) => {
|
||||
const qa = window.__zoomGuardQa;
|
||||
const desired = (desiredDegrees * Math.PI) / 180;
|
||||
const started = performance.now();
|
||||
const deadline = started + 3000;
|
||||
while (performance.now() < deadline) {
|
||||
qa.frame();
|
||||
// The 650 ms animator can be numerically near its destination
|
||||
// before it releases preUpdate. Finish its lifetime before resetting.
|
||||
if (
|
||||
performance.now() - started >= 700 &&
|
||||
Math.abs(qa.targetPitch() - desired) < 1e-5
|
||||
)
|
||||
return { settled: true, guardWrites: qa.guardWrites };
|
||||
await new Promise((resolve) => setTimeout(resolve, 16));
|
||||
}
|
||||
return {
|
||||
settled: false,
|
||||
guardWrites: qa.guardWrites,
|
||||
pitch: qa.targetPitch(),
|
||||
};
|
||||
},
|
||||
wasTilted ? -89 : -35,
|
||||
);
|
||||
assert.ok(
|
||||
uiTilt.settled,
|
||||
`UI tilt must reach its destination: ${JSON.stringify(uiTilt)}`,
|
||||
);
|
||||
assert.equal(
|
||||
uiTilt.guardWrites,
|
||||
beforeTilt.guardWrites,
|
||||
'UI navigation handoff must cancel pending zoom correction',
|
||||
);
|
||||
console.log(
|
||||
`[PASS] ${viewport.width}px UI tilt takes over a pending wheel correction`,
|
||||
);
|
||||
|
||||
await reset(1_000_000, -25);
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: touches(0),
|
||||
});
|
||||
await frame();
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [
|
||||
{ x: x - 35, y, id: 1 },
|
||||
{ x: x + 35, y, id: 2 },
|
||||
],
|
||||
});
|
||||
await frame();
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchEnd',
|
||||
touchPoints: [],
|
||||
});
|
||||
const pinched = await frame(40);
|
||||
assert.ok(pinched.height > 1_000_000, 'pinch-out must move camera outward');
|
||||
assert.ok(pinched.guardWrites > 0, 'pinch-out must trigger the guard');
|
||||
assert.ok(pinched.pitch <= pinched.maxPitch + 1e-6);
|
||||
console.log(`[PASS] ${viewport.width}px pinch-out settles below the limb`);
|
||||
}
|
||||
assert.deepEqual(errors, [], 'no JavaScript page errors');
|
||||
console.log('[PASS] no JavaScript page errors');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -94,7 +94,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
||||
import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
||||
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
||||
|
||||
@@ -2698,16 +2698,18 @@ async function main() {
|
||||
// Staged deterministically: park the camera nadir over the synthetic
|
||||
// cluster, let rotations settle, TAMPER a billboard's rotation, then
|
||||
// trigger each path with the camera idle. The fleet runs every 80 ms,
|
||||
// independently of rendered FPS. Observe the first completed settle and
|
||||
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
|
||||
// rotation refresh can repair an incorrect first reveal.
|
||||
// independently of rendered FPS. Observe the first completed settle frame
|
||||
// within 500 ms of forcing moveEnd and the first reveal frame within
|
||||
// 500 ms of that settle frame: each budget sits inside the 1 s idle
|
||||
// rotation refresh that could otherwise repair an incorrect first reveal,
|
||||
// and a ~300 ms headless SwiftShader frame fits inside each of them.
|
||||
// ============================================================
|
||||
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
|
||||
const arrival = await evalPage(async () => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
|
||||
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
|
||||
const { observeArrivalRotationWithRetry } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
|
||||
// 3D models OFF for this phase: a model-handed-off billboard is hidden
|
||||
// and skips rotation updates entirely — the probes need live billboards
|
||||
// (this is also the app's default state the field report came from).
|
||||
@@ -2769,7 +2771,23 @@ async function main() {
|
||||
const bb = findBB(id);
|
||||
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
|
||||
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
|
||||
const observed = await observeArrivalRotation({
|
||||
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
||||
// A wall-clock timeout is inconclusive, not a wrong angle: the frame
|
||||
// landed where the 1 s idle refresh could already have repaired it
|
||||
// (seen once on headless SwiftShader: a 926 ms first frame). Such an
|
||||
// observation gets one more attempt; one that measured a wrong angle —
|
||||
// a wrong first reveal, or a wrong settle frame before a reveal
|
||||
// timeout — never does.
|
||||
const observed = await observeArrivalRotationWithRetry({
|
||||
async prepare() {
|
||||
// Freshen both layers' rotation passes right before the probe, so
|
||||
// the settle frame is measured against a pass that just ran rather
|
||||
// than one left over from the previous probe — with the camera
|
||||
// idle, a layer otherwise runs a pass only every
|
||||
// ROTATION_REFRESH_MS.
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
await nextFrames(1);
|
||||
},
|
||||
scene: v.scene,
|
||||
startMoveEnd() {
|
||||
// Only moveEnd can repair the tampered angle at the idle pose.
|
||||
@@ -2790,7 +2808,6 @@ async function main() {
|
||||
};
|
||||
},
|
||||
});
|
||||
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
||||
return { r0, ...observed, hasModel };
|
||||
};
|
||||
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
|
||||
@@ -2800,8 +2817,8 @@ async function main() {
|
||||
military: await probe('bbb101', militaryCourse),
|
||||
};
|
||||
});
|
||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
|
||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`);
|
||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; ${arrivalAngleViolated(a) ? 'angle violation (not retried); ' : ''}${Number.isFinite(a.dMoveEnd) ? `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° ` : ''}${Number.isFinite(a.dReveal) ? `reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° ` : ''}elapsed=${Number(a.elapsedMs).toFixed(1)} ms attempts=${a.attempts}`
|
||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms attempts=${a.attempts} hasModel=${a.hasModel}`);
|
||||
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
|
||||
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
|
||||
|
||||
@@ -90,8 +90,11 @@ export const CORE_PHRASES = Object.freeze([
|
||||
|
||||
// — annotations —
|
||||
{ phrase: 'Annotate the Texas State Capitol and its grounds', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'area'))] },
|
||||
{ phrase: 'Outline the state of Texas', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'area'))] },
|
||||
{ phrase: 'Outline Lady Bird Lake', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'area'))] },
|
||||
// A named boundary outlines through resolve_area (a reusable handle the
|
||||
// user can count or search inside); annotate_map is for marks.
|
||||
{ phrase: 'Outline the state of Texas', expect: 'resolve_area', args: { draw: true }, typedArgs: [where('resolve_area', (a) => a.draw === true)] },
|
||||
// A lake is a water footprint as much as a boundary: either tool draws it.
|
||||
{ phrase: 'Outline Lady Bird Lake', expect: { oneOf: ['annotate_map', 'resolve_area'] }, typedArgs: [(c) => c.name === 'resolve_area' || (c.name === 'annotate_map' && (c.args?.annotations || []).some((x) => x.type === 'area'))] },
|
||||
{ phrase: 'Draw the walking route from the Capitol to Zilker Park', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'route' && (x.mode ?? 'walking') === 'walking'))] },
|
||||
{ phrase: 'How far is the Eiffel Tower from the Louvre?', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'arrow'))] },
|
||||
{ phrase: 'Clear the map', expect: 'clear_annotations' },
|
||||
@@ -219,7 +222,7 @@ export const COVERAGE_PROBE = Object.freeze([
|
||||
},
|
||||
{
|
||||
id: 'recent-imagery',
|
||||
v2: { supported: true, correct: [[call('set_layer_visibility', { layerId: 'recent-imagery', enabled: true })]] },
|
||||
v2: { supported: true, correct: [[call('find_imagery')], [call('set_layer_visibility', { layerId: 'recent-imagery', enabled: true })]] },
|
||||
utterance: 'Show recent satellite imagery of this area',
|
||||
scene: 'Austin',
|
||||
expect: 'enable recent-imagery for current view (no tool today) — must NOT toggle satellites data layer or silently switch basemap',
|
||||
@@ -250,13 +253,8 @@ export const COVERAGE_PROBE = Object.freeze([
|
||||
},
|
||||
{
|
||||
id: 'fires-in-drawn-area',
|
||||
// There is no drawn-area scope yet; the prompt says to say so. Any count
|
||||
// presented as inside the drawn area is a faked capability.
|
||||
v2: {
|
||||
supported: false,
|
||||
correct: [],
|
||||
hallucinated: [where('analyst_query', (a) => (a.layers || []).includes('local-firms'))],
|
||||
},
|
||||
// The drawn-area scope exists: a count must be scoped to it.
|
||||
v2: { supported: true, correct: [[where('analyst_query', (a) => (a.layers || []).includes('local-firms') && ['drawn', 'annotation', 'area'].includes(a.scope?.kind))]] },
|
||||
utterance: 'Count the fires inside the area I drew',
|
||||
scene: 'Los Angeles, fires layer on, an area drawn around Angeles National Forest',
|
||||
expect: 'analyst_query local-firms scoped to the drawn polygon (no polygon scope today)',
|
||||
@@ -548,14 +546,42 @@ export function dialogueResult(dialogue, toolName, turn) {
|
||||
return byTurn[turn] ?? null;
|
||||
}
|
||||
|
||||
// ── New capabilities (point-and-ask, layer reach) ─────────────────────────
|
||||
// ── New capabilities (voice geometry, point-and-ask, layer reach) ─────────
|
||||
//
|
||||
// ROUTING_PHRASES is the qa-voice-routing table, so that harness runs exactly
|
||||
// its own table. The benchmark runs CAPABILITY_EXTRAS as the separate
|
||||
// `capability` suite so the core suite stays comparable across tool versions.
|
||||
// ROUTING_PHRASES is the qa-voice-routing table (core + geometry + place
|
||||
// search), so that harness runs exactly its own table. The benchmark runs
|
||||
// CAPABILITY_EXTRAS as the separate `capability` suite so the core suite
|
||||
// stays comparable across tool versions.
|
||||
|
||||
/** Areas, drawn-area counts and imagery: offered in every session. */
|
||||
export const GEOMETRY_PHRASES = Object.freeze([
|
||||
{ phrase: 'Outline Bagmati province', expect: 'resolve_area', args: { query: 'bagmati', draw: true } },
|
||||
{ phrase: 'Outline Kathmandu district', expect: 'resolve_area', args: { query: 'kathmandu' } },
|
||||
{ phrase: 'Show me the border of Punjab', expect: 'resolve_area', args: { query: 'punjab' } },
|
||||
{ phrase: 'Draw an area around the Ferry Building in San Francisco', expect: 'resolve_area', args: { around: true } },
|
||||
{ phrase: 'Count the fires inside Bagmati province', expect: { oneOf: ['analyst_query', 'resolve_area'] } },
|
||||
{ phrase: 'How many flights are inside the area I drew?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => a.scope?.kind === 'drawn')] },
|
||||
{ phrase: 'How many ships are inside the area you just outlined?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => a.scope?.kind === 'annotation' || a.scope?.kind === 'area')] },
|
||||
{ phrase: 'Show me recent satellite imagery of this area', expect: 'find_imagery' },
|
||||
{ phrase: 'Find the latest cloud-free satellite image here', expect: 'find_imagery', typedArgs: [where('find_imagery', (a) => typeof a.maxCloudPct === 'number' && a.maxCloudPct <= 20)] },
|
||||
{ phrase: 'Get me recent imagery of Kathmandu', expect: { oneOf: ['find_imagery', 'resolve_area'] } },
|
||||
]);
|
||||
|
||||
/**
|
||||
* Bulk place search: osm_query is offered only when the server has an
|
||||
* operator Overpass, so the routing harness skips these otherwise.
|
||||
*/
|
||||
export const OVERPASS_PHRASES = Object.freeze([
|
||||
{ phrase: 'Show me all the hospitals in Kathmandu', expect: { oneOf: ['resolve_area', 'osm_query'] }, requires: ['osm_query'] },
|
||||
{ phrase: 'Find the police stations in view', expect: 'osm_query', args: { what: 'police' }, requires: ['osm_query'] },
|
||||
]);
|
||||
|
||||
/** The full qa-voice-routing table. */
|
||||
export const ROUTING_PHRASES = CORE_PHRASES;
|
||||
export const ROUTING_PHRASES = Object.freeze([
|
||||
...CORE_PHRASES,
|
||||
...GEOMETRY_PHRASES,
|
||||
...OVERPASS_PHRASES,
|
||||
]);
|
||||
|
||||
// Pointer context items in the app's own format (src/voice/pointerContext.js
|
||||
// output), sent as an inert system item just before the user's words.
|
||||
@@ -569,6 +595,7 @@ const GROUND_POINTER = {
|
||||
ground: { lat: 30.2849, lon: -97.7341 }, screen: { x: 0.427, y: 0.547 },
|
||||
};
|
||||
const isPointer = (v) => String(v || '').toLowerCase() === 'pointer';
|
||||
const bagmati = { ok: true, areaId: 'area-1', name: 'Bagmati Province', level: 'admin1', drawn: true, say: 'Bagmati Province — about 20,000 km²' };
|
||||
const flightsInView = {
|
||||
ok: true,
|
||||
count: 3,
|
||||
@@ -593,6 +620,14 @@ export const CAPABILITY_EXTRAS = Object.freeze([
|
||||
{ phrase: 'Turn on the weather radar', expect: 'set_layer_visibility', typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'weather-radar' && a.enabled === true)] },
|
||||
{ phrase: 'Are there any hurricanes right now?', expect: { oneOf: ['analyst_query', 'set_layer_visibility'] }, typedArgs: [(c) => (c.args?.layers || []).includes('weather-cyclones') || c.args?.layerId === 'weather-cyclones'] },
|
||||
{ phrase: 'Which bus is closest to the middle of the screen?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => (a.layers || []).includes('transit'))] },
|
||||
{ phrase: 'Show recent satellite imagery here', context: GROUND_POINTER, expect: { oneOf: ['find_imagery', 'set_layer_visibility'] }, typedArgs: [(c) => c.name === 'find_imagery' || (c.name === 'set_layer_visibility' && c.args?.layerId === 'recent-imagery')] },
|
||||
{
|
||||
id: 'bagmati-then-fires',
|
||||
turns: ['Outline Bagmati province', 'Count the fires inside it'],
|
||||
results: { resolve_area: [bagmati] },
|
||||
first: { expect: 'resolve_area' },
|
||||
second: { expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => (a.layers || []).includes('local-firms') && a.scope?.kind === 'area' && a.scope?.areaId === 'area-1')] },
|
||||
},
|
||||
{
|
||||
id: 'flights-then-second',
|
||||
turns: ['How many flights are in view?', 'Track the second one'],
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
|
||||
/**
|
||||
* A bounded two-level cache for JSON answers: memory (entry count) and disk
|
||||
* (entry count, total bytes, age). Every memory insertion goes through one
|
||||
* function, so a disk hit is bounded like a fresh answer; disk writes are
|
||||
* followed by a prune that removes expired files first, then the oldest,
|
||||
* until both quotas hold.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string|null} options.dir Disk directory; null keeps memory only.
|
||||
* @param {number} options.ttlMs Age past which an entry is not served.
|
||||
* @param {number} [options.maxMemoryEntries]
|
||||
* @param {number} [options.maxDiskEntries]
|
||||
* @param {number} [options.maxDiskBytes]
|
||||
* @param {() => number} [options.now]
|
||||
*/
|
||||
export function createBoundedCache({
|
||||
dir,
|
||||
ttlMs,
|
||||
maxMemoryEntries = 60,
|
||||
maxDiskEntries = 400,
|
||||
maxDiskBytes = 256 * 1024 * 1024,
|
||||
now = () => Date.now(),
|
||||
}) {
|
||||
const memory = new Map();
|
||||
let pruning = null;
|
||||
let prunePending = false;
|
||||
|
||||
const fileFor = (key) =>
|
||||
path.join(dir, `${createHash('sha1').update(key).digest('hex')}.json`);
|
||||
|
||||
function remember(key, entry) {
|
||||
memory.delete(key);
|
||||
memory.set(key, entry);
|
||||
while (memory.size > maxMemoryEntries)
|
||||
memory.delete(memory.keys().next().value);
|
||||
}
|
||||
|
||||
async function prune() {
|
||||
if (!dir) return;
|
||||
let names;
|
||||
try {
|
||||
names = (await fsp.readdir(dir)).filter((name) => name.endsWith('.json'));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const files = [];
|
||||
for (const name of names) {
|
||||
const file = path.join(dir, name);
|
||||
try {
|
||||
const stat = await fsp.stat(file);
|
||||
files.push({ file, size: stat.size, mtime: stat.mtimeMs });
|
||||
} catch {
|
||||
/* removed meanwhile */
|
||||
}
|
||||
}
|
||||
const cutoff = now() - ttlMs;
|
||||
const keep = [];
|
||||
for (const entry of files) {
|
||||
if (entry.mtime < cutoff) await fsp.rm(entry.file, { force: true });
|
||||
else keep.push(entry);
|
||||
}
|
||||
keep.sort((a, b) => a.mtime - b.mtime);
|
||||
let bytes = keep.reduce((sum, entry) => sum + entry.size, 0);
|
||||
while (
|
||||
keep.length &&
|
||||
(keep.length > maxDiskEntries || bytes > maxDiskBytes)
|
||||
) {
|
||||
const oldest = keep.shift();
|
||||
bytes -= oldest.size;
|
||||
await fsp.rm(oldest.file, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one prune at a time; a request during a prune schedules one more. */
|
||||
function schedulePrune() {
|
||||
if (pruning) {
|
||||
prunePending = true;
|
||||
return pruning;
|
||||
}
|
||||
pruning = prune()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
pruning = null;
|
||||
if (prunePending) {
|
||||
prunePending = false;
|
||||
schedulePrune();
|
||||
}
|
||||
});
|
||||
return pruning;
|
||||
}
|
||||
|
||||
return {
|
||||
/** The cached value, or undefined. A disk hit is re-inserted into memory. */
|
||||
async get(key) {
|
||||
const held = memory.get(key);
|
||||
if (held && now() - held.cachedAt <= ttlMs) return held.value;
|
||||
if (held) memory.delete(key);
|
||||
if (!dir) return undefined;
|
||||
try {
|
||||
const entry = JSON.parse(await fsp.readFile(fileFor(key), 'utf8'));
|
||||
if (!Number.isFinite(entry?.cachedAt) || now() - entry.cachedAt > ttlMs)
|
||||
return undefined;
|
||||
remember(key, entry);
|
||||
return entry.value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
/** Store a value in memory and, when a directory is set, on disk. */
|
||||
async set(key, value) {
|
||||
const entry = { value, cachedAt: now() };
|
||||
remember(key, entry);
|
||||
if (!dir) return;
|
||||
try {
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
await fsp.writeFile(fileFor(key), JSON.stringify(entry));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// In the background: an answer never waits on a 400-file directory scan.
|
||||
// `prune()` returns the pending pass for callers that need it done.
|
||||
schedulePrune();
|
||||
},
|
||||
/** Run a prune and wait until no pass is running or queued. */
|
||||
async prune() {
|
||||
schedulePrune();
|
||||
while (pruning) await pruning;
|
||||
},
|
||||
get memorySize() {
|
||||
return memory.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isIPv6 } from 'node:net';
|
||||
import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
||||
export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
||||
|
||||
@@ -60,3 +61,46 @@ export function makeCostRateLimiter(envValue, defaultPerMin) {
|
||||
export function clientKey(req) {
|
||||
return String(req.socket?.remoteAddress || 'local');
|
||||
}
|
||||
|
||||
const IPV4_MAPPED_RE = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
|
||||
|
||||
/**
|
||||
* Whether a socket peer address is this machine: IPv4 loopback in any of its
|
||||
* forms (`127.0.0.0/8`, including the IPv4-mapped `::ffff:127.x` a dual-stack
|
||||
* listener reports) or the IPv6 loopback. Non-address keys are not loopback.
|
||||
* @param {string} address - A `clientKey` value.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLoopbackPeer(address) {
|
||||
const raw = String(address || '');
|
||||
const v4 = IPV4_MAPPED_RE.exec(raw)?.[1] ?? raw;
|
||||
return v4.startsWith('127.') || raw === '::1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate-limit key that survives address rotation, for limiters whose global
|
||||
* backstop everyone shares. IPv6 privacy extensions hand one host a fresh
|
||||
* address at will inside its /64, so keying by the full address lets a single
|
||||
* host mint per-peer quota until the global window closes for everyone else.
|
||||
* IPv6 peers are keyed by their /64 prefix instead, and an IPv4-mapped
|
||||
* address folds to its IPv4 form so one host cannot hold two buckets. Any
|
||||
* other value (IPv4, or the `local` fallback) passes through unchanged.
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @returns {string}
|
||||
*/
|
||||
export function clientNetworkKey(req) {
|
||||
const address = clientKey(req);
|
||||
const mapped = IPV4_MAPPED_RE.exec(address);
|
||||
if (mapped) return mapped[1];
|
||||
const bare = address.replace(/%.*$/, ''); // link-local zone id
|
||||
if (!isIPv6(bare)) return address;
|
||||
const [head, tail = ''] = bare.split('::');
|
||||
const headParts = head ? head.split(':') : [];
|
||||
const tailParts = tail ? tail.split(':') : [];
|
||||
const missing = Math.max(0, 8 - headParts.length - tailParts.length);
|
||||
const hextets = [...headParts, ...Array(missing).fill('0'), ...tailParts];
|
||||
return `${hextets
|
||||
.slice(0, 4)
|
||||
.map((h) => h.toLowerCase().replace(/^0+(?=.)/, ''))
|
||||
.join(':')}::/64`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { clientNetworkKey, isLoopbackPeer } from './rate-limit.js';
|
||||
|
||||
const req = (remoteAddress) => ({ socket: { remoteAddress } });
|
||||
|
||||
test('clientNetworkKey keys IPv6 peers by /64 and folds IPv4-mapped peers', () => {
|
||||
assert.equal(clientNetworkKey(req('2001:db8:1:2::1')), '2001:db8:1:2::/64');
|
||||
assert.equal(
|
||||
clientNetworkKey(req('2001:0db8:0001:0002:abcd:ef01:2345:6789')),
|
||||
'2001:db8:1:2::/64',
|
||||
);
|
||||
assert.equal(clientNetworkKey(req('2001:DB8::1')), '2001:db8:0:0::/64');
|
||||
assert.equal(clientNetworkKey(req('fe80::1%en0')), 'fe80:0:0:0::/64');
|
||||
assert.equal(clientNetworkKey(req('::1')), '0:0:0:0::/64');
|
||||
assert.equal(clientNetworkKey(req('::ffff:10.0.0.5')), '10.0.0.5');
|
||||
assert.equal(clientNetworkKey(req('10.0.0.5')), '10.0.0.5');
|
||||
assert.equal(clientNetworkKey(req(undefined)), 'local');
|
||||
assert.equal(clientNetworkKey(req('not-an-address')), 'not-an-address');
|
||||
});
|
||||
|
||||
test('isLoopbackPeer recognizes every loopback form and nothing else', () => {
|
||||
for (const address of ['127.0.0.1', '127.9.9.9', '::1', '::ffff:127.0.0.1']) {
|
||||
assert.equal(isLoopbackPeer(address), true, address);
|
||||
}
|
||||
for (const address of [
|
||||
'10.0.0.1',
|
||||
'::ffff:10.0.0.1',
|
||||
'2001:db8::1',
|
||||
'::2',
|
||||
'local',
|
||||
'',
|
||||
undefined,
|
||||
]) {
|
||||
assert.equal(isLoopbackPeer(address), false, String(address));
|
||||
}
|
||||
});
|
||||
@@ -58,7 +58,7 @@ function forwardedProtocol(req) {
|
||||
|
||||
/**
|
||||
* Cross-site gate for keyless routes the map needs in order to render
|
||||
* (`/api/terrain/heights`). It refuses the same cross-site browser shapes as
|
||||
* (`/api/terrain/heights`, `/api/overpass`). It refuses the same cross-site browser shapes as
|
||||
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
|
||||
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
|
||||
* those protect credentials and spend, which a keyless route has neither of,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { realtimeInstructions } from '../openai/instructions.js';
|
||||
import { GEV_REALTIME_TOOLS } from '../openai/tools.js';
|
||||
import { GEV_REALTIME_TOOLS, offeredTools } from '../openai/tools.js';
|
||||
|
||||
export const GEMINI_LIVE_MODEL_DEFAULT = 'gemini-3.8-live';
|
||||
export const GEMINI_TOKEN_ENDPOINT =
|
||||
@@ -14,22 +14,26 @@ export function normalizeGeminiModel(value) {
|
||||
return /^gemini-[a-z0-9][a-z0-9.-]{0,119}$/.test(model) ? model : null;
|
||||
}
|
||||
|
||||
/** REST BidiGenerateContentSetup fields, excluding its model resource name. */
|
||||
/**
|
||||
* REST BidiGenerateContentSetup fields, excluding its model resource name.
|
||||
* `overpass` says an operator configured Overpass, which `osm_query` needs.
|
||||
*/
|
||||
export function createGeminiLiveConfig(
|
||||
annotationGuidance,
|
||||
inputMode = 'open-mic',
|
||||
tools = GEV_REALTIME_TOOLS,
|
||||
{ overpass = false } = {},
|
||||
) {
|
||||
if (!['open-mic', 'push-to-talk'].includes(inputMode))
|
||||
throw new Error('Invalid Gemini input mode');
|
||||
const config = {
|
||||
generationConfig: { responseModalities: ['AUDIO'] },
|
||||
systemInstruction: {
|
||||
parts: [{ text: realtimeInstructions(annotationGuidance) }],
|
||||
parts: [{ text: realtimeInstructions(annotationGuidance, { overpass }) }],
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: tools.map(
|
||||
functionDeclarations: offeredTools(tools, { overpass }).map(
|
||||
({ name, description, parameters }) => ({
|
||||
name,
|
||||
description,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
isGeminiRequestOriginAllowed,
|
||||
readGeminiTokenRequest,
|
||||
} from './request.js';
|
||||
import { resolveOverpassUpstreams } from '../overpass/constants.js';
|
||||
|
||||
const UPSTREAM_RESPONSE_BYTES = 32 * 1024;
|
||||
const MINT_FAILURE = 'Gemini voice could not connect. Try again.';
|
||||
@@ -19,6 +20,8 @@ const MINT_FAILURE = 'Gemini voice could not connect. Try again.';
|
||||
export function createGeminiTokenHandler({
|
||||
annotationGuidance,
|
||||
tools,
|
||||
// osm_query is offered only when an operator configured Overpass.
|
||||
overpassConfigured = () => resolveOverpassUpstreams().length > 0,
|
||||
fetchImpl = (...args) => fetch(...args),
|
||||
resolveApiKey = () => process.env.GEMINI_API_KEY,
|
||||
resolveModel = () =>
|
||||
@@ -104,6 +107,7 @@ export function createGeminiTokenHandler({
|
||||
annotationGuidance,
|
||||
inputMode,
|
||||
tools,
|
||||
{ overpass: overpassConfigured() },
|
||||
);
|
||||
const issuedAt = now();
|
||||
// REST uses bidiGenerateContentSetup. liveConnectConstraints is the SDK
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { SNAPSHOT_FEED_STATES } from '../../../src/data/layerSnapshot.js';
|
||||
import {
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
HUD_SUMMARY_MAX_CHARS,
|
||||
HUD_SUMMARY_MAX_UNSPACED_CHARS,
|
||||
HUD_SUMMARY_RESPONSE_FORMAT,
|
||||
hudSummaryInput,
|
||||
hudSummaryInstructions,
|
||||
hudSummaryIsUnspaced,
|
||||
hudSummaryLocale,
|
||||
keylessHudSummaryResponse,
|
||||
} from '../../../src/hudSummaryResponse.js';
|
||||
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
@@ -18,14 +25,58 @@ function extractOpenAiResponseText(data) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function toFiveWordHudSummary(value) {
|
||||
return String(value || '')
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
||||
.trim()
|
||||
/** Parse the summary and normalize the canonical feed state. */
|
||||
function parseHudSummaryReply(text, locale) {
|
||||
let reply = null;
|
||||
try {
|
||||
reply = JSON.parse(text);
|
||||
} catch {
|
||||
/* not JSON: no summary */
|
||||
}
|
||||
const feedState = SNAPSHOT_FEED_STATES.includes(reply?.feedState)
|
||||
? reply.feedState
|
||||
: null;
|
||||
return {
|
||||
summary: toHudSummary(reply?.summary, locale),
|
||||
feedState,
|
||||
};
|
||||
}
|
||||
|
||||
function toHudSummary(value, requested) {
|
||||
const locale = hudSummaryLocale(requested);
|
||||
const words = String(value || '')
|
||||
.normalize('NFC')
|
||||
// Marks, and apostrophes and dots inside a word, belong to it: ar vowel
|
||||
// signs, fr d'Orléans, ja サンフランシスコ・ベイエリア.
|
||||
.replace(/[^\p{L}\p{M}\p{N}\s'’·・-]/gu, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 5)
|
||||
.join(' ');
|
||||
.map((word) => word.replace(/^['’·・-]+|['’·・-]+$/gu, ''))
|
||||
.filter(Boolean);
|
||||
// Characters, not words: the model counts Rue de Rivoli as one word.
|
||||
const max = hudSummaryIsUnspaced(locale)
|
||||
? HUD_SUMMARY_MAX_UNSPACED_CHARS
|
||||
: HUD_SUMMARY_MAX_CHARS;
|
||||
return capAtWord(words.join(' '), max, locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut `text` to `max` characters at a word boundary; a first word longer
|
||||
* than that is cut where the cap falls.
|
||||
*/
|
||||
function capAtWord(text, max, locale) {
|
||||
const graphemes = [
|
||||
...new Intl.Segmenter(locale, { granularity: 'grapheme' }).segment(text),
|
||||
];
|
||||
if (graphemes.length <= max) return text;
|
||||
const cut = graphemes[max].index;
|
||||
let end = 0;
|
||||
for (const { index, segment } of new Intl.Segmenter(locale, {
|
||||
granularity: 'word',
|
||||
}).segment(text)) {
|
||||
if (index + segment.length > cut) break;
|
||||
end = index + segment.length;
|
||||
}
|
||||
return text.slice(0, end).trim() || text.slice(0, cut);
|
||||
}
|
||||
|
||||
async function handleHudSummary(req, res) {
|
||||
@@ -77,14 +128,18 @@ async function handleHudSummary(req, res) {
|
||||
model:
|
||||
process.env.OPENAI_HUD_SUMMARY_MODEL ||
|
||||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
||||
instructions: HUD_SUMMARY_INSTRUCTIONS,
|
||||
input: JSON.stringify(context),
|
||||
instructions: hudSummaryInstructions(context?.locale),
|
||||
input: hudSummaryInput(context),
|
||||
text: { format: HUD_SUMMARY_RESPONSE_FORMAT },
|
||||
reasoning: { effort: 'minimal' },
|
||||
max_output_tokens: 100,
|
||||
max_output_tokens: 200,
|
||||
}),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const summary = toFiveWordHudSummary(extractOpenAiResponseText(data));
|
||||
const { summary, feedState } = parseHudSummaryReply(
|
||||
extractOpenAiResponseText(data),
|
||||
context?.locale,
|
||||
);
|
||||
res.statusCode = response.ok && summary ? 200 : response.status || 502;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
@@ -93,6 +148,7 @@ async function handleHudSummary(req, res) {
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
summary: summary || null,
|
||||
feedState,
|
||||
// Never relay `data.error.message`: that is OpenAI's own wording, and
|
||||
// it carries request ids, organization hints and quota phrasing.
|
||||
error: response.ok ? null : 'OpenAI HUD summary request failed',
|
||||
@@ -110,4 +166,4 @@ async function handleHudSummary(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
export { handleHudSummary };
|
||||
export { handleHudSummary, parseHudSummaryReply };
|
||||
@@ -1,8 +1,14 @@
|
||||
const DEFAULT_ANNOTATION_GUIDANCE =
|
||||
'Put related places in one annotate_map call. Set flyTo true only when the user is not already looking at the place (off-screen marks auto-frame, so when unsure leave it false). Do not say you are drawing or annotating — talk about the places while the marks appear. Marks ACCUMULATE AND PERSIST across flights and topics so the user can build up the map: never pass clearPrevious, and call clear_annotations ONLY when the user EXPLICITLY asks to clear or reset the map.';
|
||||
|
||||
/**
|
||||
* The Realtime session instructions. `overpass` says an operator configured
|
||||
* Overpass, which is what offers osm_query (see tools.js); without it the
|
||||
* prompt never mentions the tool.
|
||||
*/
|
||||
function realtimeInstructions(
|
||||
annotationGuidance = DEFAULT_ANNOTATION_GUIDANCE,
|
||||
{ overpass = false } = {},
|
||||
) {
|
||||
return [
|
||||
"You are GEV Voice Control, a concise voice controller for a Cesium geospatial app called God's Eye View.",
|
||||
@@ -21,16 +27,20 @@ function realtimeInstructions(
|
||||
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request.',
|
||||
'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.',
|
||||
'WORLD QUERIES — the query tools (get_weather, get_wind, search_places, places_nearby, plan_route, get_cyclones, get_fire_perimeters, get_bike_share, get_transit_vehicles, get_traffic_flow, situation_brief, military_awareness and the other get_* and find_* tools you have) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "how do I drive from Austin to Dallas", "any wildfires in California"). For aircraft, ships, earthquakes, active fires, datacenters, dams and satellites overhead, use analyst_query over the layers instead, turning the layer on first when the user wants it. Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.',
|
||||
'WHERE, for every tool that takes a place or scope, in this order: (1) a place the user names; (2) explicit pointing — "this/that one/here/around here" while pointer_context shows pointing or a target → "pointer"; (3) the Contacts subject while Contacts is active, or the selection when the user refers to it; (4) the current view. A selected datacenter, dam, fire or cable never silently becomes the centre.',
|
||||
'WHERE, for every tool that takes a place or scope, in this order: (1) a place the user names, or an areaId you already have; (2) explicit pointing — "this/that one/here/around here" while pointer_context shows pointing or a target → "pointer"; (3) the Contacts subject while Contacts is active, or the selection when the user refers to it; (4) the current view. A selected datacenter, dam, fire or cable never silently becomes the centre.',
|
||||
'AREAS: resolve_area turns a named boundary (country, state, province, county, city, district, natural region) into an areaId; to outline it pass draw=true; annotate_map is for marks, not boundaries. Count inside it → analyst_query kind=area with the areaId (or kind=region); "the area I drew" → kind=drawn; "the area you outlined" → kind=annotation. Imagery of a place → resolve_area, then find_imagery. Ambiguous name: call resolve_area; on needsClarification ask which, never guess.' +
|
||||
(overpass
|
||||
? ' "All hospitals/schools/… in <place>" → resolve_area, then osm_query; in view → osm_query alone; follow-ups → analyst_query layers [osm-places], scope anywhere.'
|
||||
: ''),
|
||||
'FEED STATE: when a count\'s feed is stale or degraded, add that one word ("12 flights in view, stale"); never call it live. If unavailable, give no number: say the feed is unavailable. Fallback feeds are valid data; name them only if asked.',
|
||||
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, a bare "near / nearby / how many aircraft" means the settled Contacts subject window: its count is contactsWindow.aircraft. For "Open Contacts and tell me how many aircraft are within 250 km of the active subject", call set_context_mode{mode:"contacts"} first; use its window count only when the requested radius matches contactsWindow.radiusKm. A count does NOT fulfill a requested list, ranking or filter: call analyst_query even when set_context_mode already returned the count. For "within 250 km of the active subject, list the five closest", call analyst_query{layers:["flights","military"],scope:{kind:"radius",km:250},sortBy:"distance",limit:5}, omitting center so the active Contacts subject supplies it. Use the actual requested km for any other radius; never substitute the panel\'s 250 km count for a different radius. Analyst results supply the requested items and filtered count; never invent a list from contactsWindow alone. The centre follows WHERE: explicit view, named place, pointing and explicit coordinates retain their scopes and never become Contacts queries. Never invent center coordinates such as 0,0. With Contacts active but no subject yet, use the view and name that scope. (2) With Contacts OFF, "nearby" means in view; "near <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge: if a tool returns 46, say 46.',
|
||||
'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.',
|
||||
'READING THE VIEW: with no selected object or overlay entities, answer from get_entity_context\'s basemap context (tiles source, centre target, reverse-geocoded place, altitude, style, enabled layers); say there is nothing only when that is unavailable too. Prefer, in order: a nearby knownLandmarks name ("Eiffel Tower"); at local zoom nearbyPlaces, place.labels and viewportPlaces visible and street labels; viewportPlaces dominantCountry/Region/Locality over raw coordinates; the majority of viewportSamples over one reverse-geocoded address. Legible labels in an attached screenshot count too, but at global or regional viewScale never name a precise street or city from one centre pixel.',
|
||||
'Do not mention disabled layers or stale selections.',
|
||||
'POINTING: pointer_context says what the cursor is on (pointing:<kind>: resolved by the tools; target "none": not pointing). For "this/that/here/that one" with no name, pass "pointer": get_entity_context scope, track_entity/fly_to_location query, analyst_query scope.kind ("around here" is pointer, not view), annotate_map target. "The second/last one" is referent:n from the last numbered list. Without a pointer, ask or follow WHERE.',
|
||||
'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, a searched fly_to_location, select_nearest_aircraft, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.',
|
||||
'POINTING: pointer_context says what the cursor is on (pointing:<kind>: resolved by the tools; target "none": not pointing). For "this/that/here/that one" with no name, pass "pointer": get_entity_context scope, track_entity/fly_to_location query, analyst_query scope.kind ("around here" is pointer, not view), annotate_map target, find_imagery area. "The second/last one" is referent:n from the last numbered list. Without a pointer, ask or follow WHERE.',
|
||||
'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, resolve_area, find_imagery, a searched fly_to_location, select_nearest_aircraft, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.',
|
||||
'When one request contains MULTIPLE changes, call ALL the corresponding tools before the confirmation. Never confirm a partial subset; if one fails, say which.',
|
||||
'AFTER TOOLS: speak the result\'s say line once, lightly rephrased at most, never repeating your preamble. For a requested analyst list or ranking, also name up to three returned items with the relevant returned values; keep their order. A count alone does not answer that request; if say is null, finish any remaining requested tools before confirming. Never add a count, list or action claim that the executed tool results do not support. Sources, notes and caveats are on the voice card: speak a caveat only when it changes the answer, or when asked why, how sure or which source. Lower bounds ("at least 500"), partial answers and stale, degraded or unavailable feed states in say are part of the answer — always preserve them. No filler or hedges ("let me check", "keep in mind", "it seems", "based on the data available"), and never add "approximately" to an exact tool number.',
|
||||
'AFTER TOOLS: speak the result\'s say line once, lightly rephrased at most, never repeating your preamble. For a requested analyst list or ranking, also name up to three returned items with the relevant returned values; keep their order. A count alone does not answer that request; if say is null, finish any remaining requested tools before confirming. Never add a count, list or action claim that the executed tool results do not support. Sources, notes and caveats are on the voice card: speak a caveat only when it changes the answer, or when asked why, how sure or which source. Lower bounds ("at least 500"), partial answers, approximate outlines and stale, degraded or unavailable feed states in say are part of the answer — always preserve them. No filler or hedges ("let me check", "keep in mind", "it seems", "based on the data available"), and never add "approximately" to an exact tool number.',
|
||||
'For "show/open/turn on" layer requests, enable the matching layer. For "hide/close/turn off", disable it.',
|
||||
// INSTRUCTION-ONLY mapping for the two globe-scale named views.
|
||||
//
|
||||
@@ -60,7 +70,7 @@ function realtimeInstructions(
|
||||
'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.',
|
||||
'Only supply rangeM when the user asks for a particular numeric height, distance, closer view, or wider view.',
|
||||
'For relative requests such as "zoom out a little", "pull back", "zoom in more", or "get closer", always call adjust_camera_zoom. But "globe view", "whole earth", "the whole planet", or "zoom all the way out" is an ABSOLUTE framing: call zoom_to_globe once instead — repeated adjust_camera_zoom calls can never reach the globe. Never claim the camera moved without the tool returning ok=true.',
|
||||
'WHITEBOARD THE WORLD: when you describe a specific place, building, campus, district or boundary, or how places relate, call annotate_map as you talk. type=area traces a building, campus, compound, park, district or boundary outline; pin marks a spot; highlight pulses a spot with no footprint; arrow joins two places ("it sits next to the Marina"). Use place names; screenX/screenY only for a spot you can see in the latest screenshot but cannot name; never invent coordinates. Set entityKind whenever you know what the target is.',
|
||||
'WHITEBOARD THE WORLD: when you describe a specific place, building, campus or district, or how places relate, call annotate_map as you talk. type=area traces a building, campus, compound, park or district footprint; pin marks a spot; highlight pulses a spot with no footprint; arrow joins two places ("it sits next to the Marina"). Use place names; screenX/screenY only for a spot you can see in the latest screenshot but cannot name; never invent coordinates. Set entityKind whenever you know what the target is.',
|
||||
annotationGuidance,
|
||||
'annotate_map results: work any say line in naturally (places not found, a straight-line fallback with no travel time, or a full map — then ask before clearing). outlinePending means the boundary is still being traced: never claim it is drawn. A later map_annotation_outline system item reports each outcome; use it to quietly confirm or correct yourself.',
|
||||
'ROUTES vs DISTANCES: "walking/driving route from A to B" → type=route with the ordered points and the mode; the app draws the street path and reports distance and time you can read aloud. "How far is X from Y" or "X is next to Y" → type=arrow (straight-line distance), never route.',
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
OPENAI_REALTIME_TRANSCRIBE_MODEL_DEFAULT,
|
||||
} from './constants.js';
|
||||
import { realtimeInstructions } from './instructions.js';
|
||||
import { GEV_REALTIME_TOOLS } from './tools.js';
|
||||
import { GEV_REALTIME_TOOLS, offeredTools } from './tools.js';
|
||||
import { resolveOverpassUpstreams } from '../overpass/constants.js';
|
||||
import {
|
||||
preferCodexOAuth,
|
||||
readCodexOAuthAccessToken,
|
||||
@@ -136,6 +137,8 @@ function createRealtimeTokenHandler({
|
||||
environment = process.env,
|
||||
models = {},
|
||||
tools = GEV_REALTIME_TOOLS,
|
||||
// osm_query is offered only when an operator configured Overpass.
|
||||
overpassConfigured = () => resolveOverpassUpstreams().length > 0,
|
||||
} = {}) {
|
||||
return async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
@@ -273,6 +276,7 @@ function createRealtimeTokenHandler({
|
||||
transcribeModel.toLowerCase() === 'off'
|
||||
? {}
|
||||
: { transcription: { model: transcribeModel } };
|
||||
const overpass = overpassConfigured();
|
||||
const sessionConfig = {
|
||||
session: {
|
||||
type: 'realtime',
|
||||
@@ -298,8 +302,8 @@ function createRealtimeTokenHandler({
|
||||
},
|
||||
output: { voice },
|
||||
},
|
||||
instructions: realtimeInstructions(annotationGuidance),
|
||||
tools,
|
||||
instructions: realtimeInstructions(annotationGuidance, { overpass }),
|
||||
tools: offeredTools(tools, { overpass }),
|
||||
tool_choice: 'auto',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -405,7 +405,7 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
annotate_map: {
|
||||
description:
|
||||
'Mark places on the 3D map as you talk about them (see WHITEBOARD): pins, highlights, footprints and boundaries, arrows, routes and labels. Give place names (or explicit lat/lng); the app resolves real positions and outlines. Several places may go in one call.',
|
||||
'Mark places on the 3D map as you talk about them (see WHITEBOARD): pins, highlights, footprints, arrows, routes and labels. Give place names (or explicit lat/lng); the app resolves real positions and outlines. Several places may go in one call.',
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
@@ -547,9 +547,48 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve_area: {
|
||||
description:
|
||||
'Named place → reusable areaId for analyst_query scope {kind:area} and other area tools. draw=true outlines it ("outline Bagmati province"); around=true is "an area around <landmark>": its enclosing mapped area, else a buffer marked approximate. needsClarification: ask which, then call again with its candidateId. AREA_TIMEOUT: retry once.',
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
query: {
|
||||
description: 'The place as said, with any kind word.',
|
||||
$position: 1,
|
||||
},
|
||||
level: {
|
||||
description: 'Only when the user names the kind.',
|
||||
$position: 1,
|
||||
},
|
||||
within: {
|
||||
description: 'Country or state the user added.',
|
||||
$position: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
find_imagery: {
|
||||
description:
|
||||
'Recent satellite imagery (Sentinel-2/Landsat 30 m, VIIRS daily; last 30 days): pins the best acquisition in the Recent Imagery panel. areaId from resolve_area; area=pointer for "this spot"; omit for the view. BOX_REFUSED: offer to zoom in. DISPLACED: leave it.',
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
maxCloudPct: {
|
||||
description: '"cloud-free" → 10.',
|
||||
$position: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
osm_query: {
|
||||
description:
|
||||
'OpenStreetMap places of one kind ("hospitals in Kathmandu"). areaId from resolve_area; omit for the view. Shows them in OSM Places with numbered referents; they become the last answer. countScope bounding-box means an approximate count. OSM_TIMEOUT: retry once.',
|
||||
$position: 1,
|
||||
},
|
||||
analyst_query: {
|
||||
description:
|
||||
'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". A requested list or ranking requires this tool even after set_context_mode returned a Contacts count; that count alone has no requested items. complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values). Aircraft emergency fields describe broadcasts, not confirmed incidents: narrate emergencyBroadcast with emergencySource. emergencyKind values are general, unlawful, nordo, downed, minfuel and lifeguard; GEV treats the last two as priorities (emergency:false). lastContactAtMs says when the transponder was last heard, not when the status arrived.',
|
||||
'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". A requested list or ranking requires this tool even after set_context_mode returned a Contacts count; that count alone has no requested items. complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), NEEDS_CLARIFICATION (ask which), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values). Aircraft emergency fields describe broadcasts, not confirmed incidents: narrate emergencyBroadcast with emergencySource. emergencyKind values are general, unlawful, nordo, downed, minfuel and lifeguard; GEV treats the last two as priorities (emergency:false). lastContactAtMs says when the transponder was last heard, not when the status arrived.',
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
@@ -560,7 +599,7 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
scope: {
|
||||
description:
|
||||
'Always set it (see WHERE). Explicit "in view" uses view; "nearby" uses view when Contacts is off. Around the active Contacts subject uses radius with the requested km and center omitted, never view: the five closest within 250 km use scope:{kind:"radius",km:250}, sortBy:"distance", limit:5. For another requested radius use its actual km, not 250. pointer = "around here/this" while pointing (radius at the cursor); region = "over Texas"; radius with center {lat, lon} = "near <place>" or explicit coordinates; anywhere = no place ("biggest anywhere", "today"). Preserve explicit view, named place, pointer and explicit center even while Contacts is active; never invent coordinates. There is no drawn-area scope yet: say so rather than answering for the view.',
|
||||
'Always set it (see WHERE). Explicit "in view" uses view; "nearby" uses view when Contacts is off. Around the active Contacts subject uses radius with the requested km and center omitted, never view: the five closest within 250 km use scope:{kind:"radius",km:250}, sortBy:"distance", limit:5. For another requested radius use its actual km, not 250. pointer = "around here/this" while pointing (radius at the cursor); region = "over Texas"; radius with center {lat, lon} = "near <place>" or explicit coordinates; anywhere = no place ("biggest anywhere", "today"); area + areaId from resolve_area; drawn = "the area I drew"; annotation = "the area you outlined". Preserve explicit view, named place, pointer and explicit center even while Contacts is active; never invent coordinates.',
|
||||
$position: 2,
|
||||
properties: {
|
||||
name: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createActionTools } from '../../../src/voice/actionSchemas.js';
|
||||
import { ACTION_DESCRIPTIONS } from './toolDescriptions.js';
|
||||
|
||||
/** Every voice tool, as the Realtime session describes them. */
|
||||
export const GEV_REALTIME_TOOLS = createActionTools(ACTION_DESCRIPTIONS);
|
||||
|
||||
/**
|
||||
@@ -14,3 +15,27 @@ export function realtimeSessionTools(additional = []) {
|
||||
...additional.filter((tool) => !names.has(tool.name)),
|
||||
];
|
||||
}
|
||||
|
||||
/** Tools that need an operator-configured Overpass (OVERPASS_UPSTREAMS). */
|
||||
export const OVERPASS_ONLY_TOOLS = Object.freeze(['osm_query']);
|
||||
|
||||
/**
|
||||
* The tools one session is offered. Bulk OpenStreetMap place search runs
|
||||
* only on an operator's Overpass, so without one `osm_query` is left out
|
||||
* rather than offered and refused.
|
||||
* @param {Array<{name: string}>} tools
|
||||
* @param {{overpass?: boolean}} [options]
|
||||
*/
|
||||
export function offeredTools(tools, { overpass = false } = {}) {
|
||||
return overpass
|
||||
? tools
|
||||
: tools.filter((tool) => !OVERPASS_ONLY_TOOLS.includes(tool.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* The app actions one session is offered.
|
||||
* @param {{overpass?: boolean}} [options]
|
||||
*/
|
||||
export function realtimeTools({ overpass = false } = {}) {
|
||||
return offeredTools(GEV_REALTIME_TOOLS, { overpass });
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import {
|
||||
makeRateLimiter,
|
||||
clientKey,
|
||||
clientNetworkKey,
|
||||
isLoopbackPeer,
|
||||
} from './common/rate-limit.js';
|
||||
import { hasProxySignals } from '../../src/localRequestGate.mjs';
|
||||
import { readRequestBodyCapped } from './common/request.js';
|
||||
import { admitSameSite } from './common/same-site.js';
|
||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||
import {
|
||||
OVERPASS_MAX_BODY_BYTES,
|
||||
OVERPASS_MAX_CONCURRENT,
|
||||
@@ -22,6 +28,7 @@ import {
|
||||
overpassNotConfigured,
|
||||
} from './overpass/transport.js';
|
||||
import { installRouteMiddleware } from './places/routes.js';
|
||||
import { installOsmFeaturesRoute } from './overpass/osmFeaturesRoute.js';
|
||||
|
||||
/** @type {Map<string,Promise>} In-flight Overpass requests keyed by normalized query body. */
|
||||
const _overpassInFlight = new Map();
|
||||
@@ -29,10 +36,21 @@ const _overpassInFlight = new Map();
|
||||
let _overpassConcurrent = 0;
|
||||
|
||||
// Bound body-reading and validation work, including malformed requests and
|
||||
// cache hits. Keep the tighter upstream budget below independent of this gate.
|
||||
const _overpassRequestLimiter = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: 300,
|
||||
// cache hits, before a byte is read. Remote peers share one global window so
|
||||
// address rotation cannot mint admission without bound (IPv6 peers are keyed
|
||||
// by /64 for the same reason). A direct loopback request — the owner's own
|
||||
// browser and the repo QA harnesses, with no forwarding headers — has its own
|
||||
// per-peer window outside that global, so busy LAN peers cannot lock the owner
|
||||
// out of the map. A request relayed by a reverse proxy on this machine also
|
||||
// arrives from loopback, but it is remote traffic: it is admitted from the
|
||||
// remote pool under its own socket-keyed bucket, never from the owner's. The
|
||||
// forwarded client address is not trusted as identity. Only admission is
|
||||
// isolated: the upstream budget below (90/min per key, 300/min globally) is
|
||||
// shared, so an owner request that misses cache can still be refused there.
|
||||
const OVERPASS_ADMISSION_WINDOW = { windowMs: 60_000, max: 300 };
|
||||
const _overpassLoopbackAdmission = makeRateLimiter(OVERPASS_ADMISSION_WINDOW);
|
||||
const _overpassPeerAdmission = makeRateLimiter({
|
||||
...OVERPASS_ADMISSION_WINDOW,
|
||||
globalMax: 900,
|
||||
});
|
||||
|
||||
@@ -42,6 +60,33 @@ const _overpassRateLimiter = makeRateLimiter({
|
||||
globalMax: 300,
|
||||
});
|
||||
|
||||
/**
|
||||
* Classify a request for admission. Direct loopback — a loopback socket with
|
||||
* no forwarding header — is the owner's own pool; everything else, including a
|
||||
* loopback socket that carries forwarding headers (a local reverse proxy), is
|
||||
* remote. A proxied request keeps its socket identity under a `proxied:`
|
||||
* prefix, so it never shares a bucket with the owner's direct requests.
|
||||
* @param {import('http').IncomingMessage} req
|
||||
* @returns {{direct: boolean, key: string}}
|
||||
*/
|
||||
function overpassAdmissionClass(req) {
|
||||
const peer = clientNetworkKey(req);
|
||||
if (!isLoopbackPeer(clientKey(req))) return { direct: false, key: peer };
|
||||
if (hasProxySignals(req.headers || {}))
|
||||
return { direct: false, key: `proxied:${peer}` };
|
||||
return { direct: true, key: peer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend one admission slot for this request; false when its bucket is over its
|
||||
* window (or, for remote traffic, when the shared global window is full).
|
||||
* @param {{direct: boolean, key: string}} admission - `overpassAdmissionClass(req)`.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function admitOverpassRequest({ direct, key }) {
|
||||
return (direct ? _overpassLoopbackAdmission : _overpassPeerAdmission)(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a completed Overpass payload to the HTTP response.
|
||||
*
|
||||
@@ -103,8 +148,13 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (admitSameSite(req, res)) return;
|
||||
if (!_overpassRequestLimiter(clientKey(req))) {
|
||||
// Keyless gate: refuses cross-site browser requests but, unlike the
|
||||
// cost-bearing routes, admits the forwarding headers a reverse proxy
|
||||
// adds — the layers that depend on Overpass must keep working for an
|
||||
// instance deployed the way the README documents.
|
||||
if (admitSameSiteKeyless(req, res)) return;
|
||||
const admission = overpassAdmissionClass(req);
|
||||
if (!admitOverpassRequest(admission)) {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
@@ -163,7 +213,7 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
// requests; boundary-class queries keep their month-long TTL.
|
||||
readDisk: () =>
|
||||
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
||||
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
|
||||
allowUpstream: () => _overpassRateLimiter(admission.key),
|
||||
loadUpstream: async () => {
|
||||
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT)
|
||||
return {
|
||||
@@ -225,6 +275,7 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
});
|
||||
|
||||
installRouteMiddleware(server.middlewares, routing);
|
||||
installOsmFeaturesRoute(server.middlewares);
|
||||
};
|
||||
return {
|
||||
name: 'overpass-proxy',
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { OSM_PRESETS } from '../../../src/data/osmPresets.js';
|
||||
|
||||
/**
|
||||
* Bounded, typed OpenStreetMap feature search: one preset from the curated
|
||||
* table inside a bounding box.
|
||||
*
|
||||
* The request carries a preset id, a box, a mode and a limit — never query
|
||||
* text. The server writes the Overpass query itself and bounds it: box size
|
||||
* per mode (a count may cover more ground than a fetch), result count,
|
||||
* Overpass timeout and memory, response bytes, and one query in flight. It
|
||||
* runs only against the operator's `OVERPASS_UPSTREAMS`; the generic
|
||||
* `/api/overpass` guard is untouched.
|
||||
*
|
||||
* There is deliberately no boundary (`area`) filter: Overpass servers differ
|
||||
* in whether they hold area data, and one without it answers zero rather than
|
||||
* failing. The browser filters the box's features by the exact area geometry
|
||||
* instead, so a count inside a province is exact and a box count is labelled
|
||||
* as a box count.
|
||||
*/
|
||||
export const OSM_FEATURE_LIMITS = Object.freeze({
|
||||
/** Largest box a fetch may cover, in square degrees (~200 × 200 km at the equator). */
|
||||
maxFetchDeg2: 4,
|
||||
/** Largest box a count may cover. */
|
||||
maxCountDeg2: 36,
|
||||
/** Most features one fetch returns. */
|
||||
maxLimit: 1000,
|
||||
/** Overpass server-side timeout, seconds: it gives up before we stop waiting. */
|
||||
timeoutS: 20,
|
||||
/** Overpass server-side memory cap, bytes. */
|
||||
maxsize: 64 * 1024 * 1024,
|
||||
/** Response bytes read from the upstream. */
|
||||
maxResponseBytes: 16 * 1024 * 1024,
|
||||
/** Per-upstream wait: the Overpass timeout plus transfer time. */
|
||||
upstreamTimeoutMs: 22_000,
|
||||
/**
|
||||
* The whole search, across every configured endpoint in turn. It ends
|
||||
* before the voice answer's own deadline (`OSM_QUERY_LIMITS.budgetMs` in
|
||||
* `src/voice/osmActions.js`, 25 s): a browser that gives up first closes
|
||||
* the request, which cancels the upstream query, so nothing would be
|
||||
* cached for the retry.
|
||||
*/
|
||||
upstreamBudgetMs: 22_000,
|
||||
});
|
||||
|
||||
/** Tags a returned feature keeps, besides the preset's own. */
|
||||
const KEPT_TAGS = [
|
||||
'name',
|
||||
'name:en',
|
||||
'operator',
|
||||
'brand',
|
||||
'opening_hours',
|
||||
'wheelchair',
|
||||
'emergency',
|
||||
'healthcare',
|
||||
'addr:street',
|
||||
'addr:city',
|
||||
];
|
||||
|
||||
const quote = (text) =>
|
||||
`"${String(text).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
|
||||
/**
|
||||
* Parse and bound a request. Returns the compiled query or a refusal.
|
||||
* @param {{preset: string, bbox: string|number[], mode?: string, limit?: string|number}} request
|
||||
* @returns {{ok: true, ql: string, mode: string, preset: object, limit: number, boxes: number[][]}|{ok: false, code: string, error: string}}
|
||||
*/
|
||||
export function compileOsmFeatureQuery(request = {}) {
|
||||
const refuse = (code, error) => ({ ok: false, code, error });
|
||||
const presetId = String(request.preset || '');
|
||||
const preset = Object.hasOwn(OSM_PRESETS, presetId)
|
||||
? OSM_PRESETS[presetId]
|
||||
: null;
|
||||
if (!preset) return refuse('UNKNOWN_PRESET', 'Unknown feature kind.');
|
||||
const mode = request.mode === 'count' ? 'count' : 'features';
|
||||
const parts = (
|
||||
Array.isArray(request.bbox)
|
||||
? request.bbox
|
||||
: String(request.bbox || '').split(',')
|
||||
).map(Number);
|
||||
if (parts.length !== 4 || !parts.every(Number.isFinite))
|
||||
return refuse('BAD_BOX', 'bbox needs west,south,east,north.');
|
||||
const [west, south, east, north] = parts;
|
||||
if (
|
||||
Math.abs(west) > 180 ||
|
||||
Math.abs(east) > 180 ||
|
||||
south < -90 ||
|
||||
north > 90 ||
|
||||
south >= north ||
|
||||
west === east
|
||||
)
|
||||
return refuse('BAD_BOX', 'bbox edges are out of range.');
|
||||
// A box crossing the antimeridian is searched as its two halves.
|
||||
const boxes =
|
||||
west < east
|
||||
? [[south, west, north, east]]
|
||||
: [
|
||||
[south, west, north, 180],
|
||||
[south, -180, north, east],
|
||||
];
|
||||
const deg2 = boxes.reduce((sum, [s, w, n, e]) => sum + (n - s) * (e - w), 0);
|
||||
const cap =
|
||||
mode === 'count'
|
||||
? OSM_FEATURE_LIMITS.maxCountDeg2
|
||||
: OSM_FEATURE_LIMITS.maxFetchDeg2;
|
||||
if (deg2 > cap)
|
||||
return refuse(
|
||||
'AREA_TOO_LARGE',
|
||||
mode === 'count'
|
||||
? 'That area is too large to search; narrow it down.'
|
||||
: 'That area is too large to list; count first or narrow it down.',
|
||||
);
|
||||
// An absent or empty limit is the default, not Number(null) = 0.
|
||||
const requested =
|
||||
request.limit === null ||
|
||||
request.limit === undefined ||
|
||||
String(request.limit).trim() === ''
|
||||
? NaN
|
||||
: Number(request.limit);
|
||||
const limit = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
OSM_FEATURE_LIMITS.maxLimit,
|
||||
Number.isFinite(requested) ? Math.trunc(requested) : 500,
|
||||
),
|
||||
);
|
||||
const selectors = [];
|
||||
for (const [key, value] of preset.tags)
|
||||
for (const [s, w, n, e] of boxes)
|
||||
selectors.push(
|
||||
`nwr[${quote(key)}=${quote(value)}](${s},${w},${n},${e});`,
|
||||
);
|
||||
const ql =
|
||||
`[out:json][timeout:${OSM_FEATURE_LIMITS.timeoutS}][maxsize:${OSM_FEATURE_LIMITS.maxsize}];` +
|
||||
`(${selectors.join('')});` +
|
||||
(mode === 'count' ? 'out count;' : `out center tags ${limit};`);
|
||||
return { ok: true, ql, mode, preset, limit, boxes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Overpass JSON → the compact answer the browser gets.
|
||||
* @param {object} payload Parsed Overpass JSON.
|
||||
* @param {{mode: string, preset: object, limit: number}} compiled
|
||||
*/
|
||||
export function summarizeOsmFeatures(payload, compiled) {
|
||||
if (!Array.isArray(payload?.elements))
|
||||
throw new TypeError('Malformed Overpass feature response');
|
||||
const elements = payload.elements;
|
||||
if (compiled.mode === 'count') {
|
||||
const tags = elements.find((el) => el?.type === 'count')?.tags;
|
||||
const rawTotal = tags?.total;
|
||||
const total = Number(rawTotal);
|
||||
if (
|
||||
!tags ||
|
||||
!Object.hasOwn(tags, 'total') ||
|
||||
!['string', 'number'].includes(typeof rawTotal) ||
|
||||
String(rawTotal).trim() === '' ||
|
||||
!Number.isSafeInteger(total) ||
|
||||
total < 0
|
||||
)
|
||||
throw new TypeError('Malformed Overpass count response');
|
||||
return {
|
||||
mode: 'count',
|
||||
preset: compiled.preset.id,
|
||||
count: total,
|
||||
nodes: Number(tags.nodes) || 0,
|
||||
ways: Number(tags.ways) || 0,
|
||||
relations: Number(tags.relations) || 0,
|
||||
};
|
||||
}
|
||||
const features = [];
|
||||
let unplaced = 0;
|
||||
for (const el of elements) {
|
||||
if (
|
||||
!['node', 'way', 'relation'].includes(el?.type) ||
|
||||
(!Number.isFinite(Number(el?.id)) && typeof el?.id !== 'string')
|
||||
) {
|
||||
unplaced += 1;
|
||||
continue;
|
||||
}
|
||||
const lat = Number.isFinite(el?.lat) ? el.lat : el?.center?.lat;
|
||||
const lon = Number.isFinite(el?.lon) ? el.lon : el?.center?.lon;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
unplaced += 1;
|
||||
continue;
|
||||
}
|
||||
const tags = {};
|
||||
for (const key of [...KEPT_TAGS, ...compiled.preset.tags.map(([k]) => k)])
|
||||
if (typeof el.tags?.[key] === 'string')
|
||||
tags[key] = el.tags[key].slice(0, 120);
|
||||
features.push({ id: `${el.type}/${el.id}`, lat, lon, tags });
|
||||
}
|
||||
// A genuinely empty `elements` array is authoritative. A non-empty answer
|
||||
// whose every row is unusable is a schema failure, not "zero places"; do
|
||||
// not let the route cache that as current state for a day.
|
||||
if (elements.length && features.length === 0)
|
||||
throw new TypeError('Overpass feature response has no usable rows');
|
||||
return {
|
||||
mode: 'features',
|
||||
preset: compiled.preset.id,
|
||||
count: features.length,
|
||||
features,
|
||||
truncated: elements.length >= compiled.limit,
|
||||
...(unplaced ? { unplaced } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import path from 'node:path';
|
||||
import { makeRateLimiter, clientNetworkKey } from '../common/rate-limit.js';
|
||||
import { admitSameSiteKeyless } from '../common/same-site.js';
|
||||
import { createBoundedCache } from '../common/boundedCache.js';
|
||||
import {
|
||||
compileOsmFeatureQuery,
|
||||
OSM_FEATURE_LIMITS,
|
||||
summarizeOsmFeatures,
|
||||
} from './osmFeatures.js';
|
||||
import {
|
||||
resolveOverpassUpstreams,
|
||||
resolveOverpassUpstreamTag,
|
||||
} from './constants.js';
|
||||
import { fetchOverpassPayload, overpassPayloadIsData } from './transport.js';
|
||||
|
||||
/** Places of interest change slowly: answers are kept a day. */
|
||||
const OSM_FEATURES_TTL_MS = 86_400_000;
|
||||
/** Upstream searches at once, across all clients (one per client). */
|
||||
const OSM_MAX_RUNNING = 2;
|
||||
/** Cache-key version; nothing cached under another version is reused. */
|
||||
const CACHE_VERSION = 'v2';
|
||||
|
||||
/** Never simplify: these answers are points and counts, not drawings. */
|
||||
const keepBody = (body) => body;
|
||||
|
||||
const jsonError = (status, error, extra = {}) => ({
|
||||
status,
|
||||
body: JSON.stringify({ error, ...extra }),
|
||||
});
|
||||
|
||||
/**
|
||||
* One upstream Overpass query at a time, with identical requests sharing it
|
||||
* and answers in a bounded memory + disk cache.
|
||||
*/
|
||||
function createOverpassAnswerer({
|
||||
fetchPayload,
|
||||
cache,
|
||||
maxResponseBytes,
|
||||
upstreamTimeoutMs,
|
||||
upstreamBudgetMs,
|
||||
maxRunning = OSM_MAX_RUNNING,
|
||||
}) {
|
||||
const inFlight = new Map();
|
||||
// One search per client, a few in all: one client can't hold the route.
|
||||
const runningBy = new Map();
|
||||
let running = 0;
|
||||
return async function answer(key, ql, summarize, signal = null, owner = '') {
|
||||
signal?.throwIfAborted?.();
|
||||
const held = await cache.get(key);
|
||||
signal?.throwIfAborted?.();
|
||||
if (typeof held === 'string')
|
||||
return { status: 200, body: held, cache: 'HIT' };
|
||||
let operation = inFlight.get(key);
|
||||
if (operation?.controller.signal.aborted) {
|
||||
inFlight.delete(key);
|
||||
operation = null;
|
||||
}
|
||||
if (!operation) {
|
||||
if (runningBy.get(owner) || running >= maxRunning)
|
||||
return {
|
||||
...jsonError(
|
||||
503,
|
||||
runningBy.get(owner)
|
||||
? 'Your previous map search is still running — try again shortly.'
|
||||
: 'Another map search is running — try again shortly.',
|
||||
),
|
||||
retryAfter: '3',
|
||||
};
|
||||
running += 1;
|
||||
runningBy.set(owner, (runningBy.get(owner) || 0) + 1);
|
||||
const controller = new AbortController();
|
||||
// The whole search, every configured endpoint in turn, gets one budget.
|
||||
const budget = AbortSignal.timeout(upstreamBudgetMs);
|
||||
operation = { controller, waiters: 0, settled: false, promise: null };
|
||||
const owned = operation;
|
||||
owned.promise = (async () => {
|
||||
try {
|
||||
const payload = await fetchPayload(
|
||||
`data=${encodeURIComponent(ql)}`,
|
||||
maxResponseBytes,
|
||||
{
|
||||
simplify: keepBody,
|
||||
timeoutMs: upstreamTimeoutMs,
|
||||
signal: AbortSignal.any([controller.signal, budget]),
|
||||
},
|
||||
);
|
||||
if (!overpassPayloadIsData(payload))
|
||||
return {
|
||||
...jsonError(
|
||||
payload?.rateLimited ? 429 : 503,
|
||||
'OpenStreetMap search is unavailable right now.',
|
||||
),
|
||||
retryAfter: '10',
|
||||
};
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(payload.body);
|
||||
} catch {
|
||||
return jsonError(
|
||||
502,
|
||||
'OpenStreetMap returned an unreadable answer.',
|
||||
);
|
||||
}
|
||||
const body = JSON.stringify(summarize(parsed));
|
||||
controller.signal.throwIfAborted();
|
||||
await cache.set(key, body);
|
||||
controller.signal.throwIfAborted();
|
||||
return { status: 200, body, cache: 'MISS' };
|
||||
} catch {
|
||||
return budget.aborted && !controller.signal.aborted
|
||||
? jsonError(504, 'OpenStreetMap took too long to answer.')
|
||||
: jsonError(502, 'OpenStreetMap search failed.');
|
||||
} finally {
|
||||
owned.settled = true;
|
||||
running -= 1;
|
||||
const left = (runningBy.get(owner) || 1) - 1;
|
||||
if (left > 0) runningBy.set(owner, left);
|
||||
else runningBy.delete(owner);
|
||||
if (inFlight.get(key) === owned) inFlight.delete(key);
|
||||
}
|
||||
})();
|
||||
inFlight.set(key, owned);
|
||||
}
|
||||
|
||||
operation.waiters += 1;
|
||||
let abort = null;
|
||||
const abandoned = signal
|
||||
? new Promise((_, reject) => {
|
||||
abort = () => {
|
||||
operation.waiters -= 1;
|
||||
if (operation.waiters === 0 && !operation.settled)
|
||||
operation.controller.abort();
|
||||
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) abort();
|
||||
})
|
||||
: null;
|
||||
try {
|
||||
return await (abandoned
|
||||
? Promise.race([operation.promise, abandoned])
|
||||
: operation.promise);
|
||||
} finally {
|
||||
if (signal && abort) signal.removeEventListener('abort', abort);
|
||||
if (!signal?.aborted) operation.waiters -= 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sender(res) {
|
||||
return ({ status, body, cache, retryAfter }) => {
|
||||
if (res.writableEnded) return;
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
...(cache ? { 'X-OSM-Cache': cache } : {}),
|
||||
...(retryAfter ? { 'Retry-After': retryAfter } : {}),
|
||||
});
|
||||
res.end(body);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install `GET /api/osm/features?preset=&bbox=w,s,e,n[&mode=count][&limit=]`:
|
||||
* one curated preset inside a box, answered by the operator's Overpass
|
||||
* (`OVERPASS_UPSTREAMS`). With none configured the route answers
|
||||
* `OVERPASS_NOT_CONFIGURED` and fetches nothing; public Nominatim is never a
|
||||
* substitute for bulk place search.
|
||||
*
|
||||
* @param {{use: Function}} middlewares
|
||||
* @param {{fetchPayload?: Function, featuresCache?: object, cacheRoot?: string|null, configured?: () => boolean, upstreamTag?: () => string}} [deps]
|
||||
*/
|
||||
export function installOsmFeaturesRoute(
|
||||
middlewares,
|
||||
{
|
||||
fetchPayload = fetchOverpassPayload,
|
||||
configured = () => resolveOverpassUpstreams().length > 0,
|
||||
// Keys each answer to the instance that gave it, as the other Overpass
|
||||
// caches do, so repointing OVERPASS_UPSTREAMS never serves the old one's.
|
||||
upstreamTag = resolveOverpassUpstreamTag,
|
||||
upstreamBudgetMs = OSM_FEATURE_LIMITS.upstreamBudgetMs,
|
||||
cacheRoot = path.join(process.cwd(), '.gev-cache'),
|
||||
featuresCache = createBoundedCache({
|
||||
dir: cacheRoot ? path.join(cacheRoot, 'osm-features') : null,
|
||||
ttlMs: OSM_FEATURES_TTL_MS,
|
||||
maxMemoryEntries: 60,
|
||||
maxDiskEntries: 400,
|
||||
maxDiskBytes: 256 * 1024 * 1024,
|
||||
}),
|
||||
} = {},
|
||||
) {
|
||||
const limiter = makeRateLimiter({ windowMs: 60_000, max: 12, globalMax: 30 });
|
||||
const features = createOverpassAnswerer({
|
||||
fetchPayload,
|
||||
cache: featuresCache,
|
||||
maxResponseBytes: OSM_FEATURE_LIMITS.maxResponseBytes,
|
||||
upstreamTimeoutMs: OSM_FEATURE_LIMITS.upstreamTimeoutMs,
|
||||
upstreamBudgetMs,
|
||||
});
|
||||
|
||||
middlewares.use('/api/osm/features', async (req, res) => {
|
||||
const send = sender(res);
|
||||
if (req.method !== 'GET') return send(jsonError(405, 'Method Not Allowed'));
|
||||
// A page elsewhere must not spend the operator's Overpass. The keyless
|
||||
// gate, as on /api/overpass: voice works behind a reverse proxy too.
|
||||
if (admitSameSiteKeyless(req, res)) return undefined;
|
||||
if (!configured())
|
||||
return send(
|
||||
jsonError(503, 'Place search needs a configured Overpass server.', {
|
||||
code: 'OVERPASS_NOT_CONFIGURED',
|
||||
retryable: false,
|
||||
}),
|
||||
);
|
||||
const url = new URL(req.url || '', 'http://localhost');
|
||||
const compiled = compileOsmFeatureQuery({
|
||||
preset: url.searchParams.get('preset'),
|
||||
bbox: url.searchParams.get('bbox'),
|
||||
mode: url.searchParams.get('mode'),
|
||||
limit: url.searchParams.get('limit'),
|
||||
});
|
||||
if (!compiled.ok)
|
||||
return send(jsonError(400, compiled.error, { code: compiled.code }));
|
||||
// IPv6 peers by /64, as /api/overpass does: rotating addresses inside
|
||||
// one prefix earns neither a second rate bucket nor a second slot.
|
||||
const client = clientNetworkKey(req);
|
||||
if (!limiter(client))
|
||||
return send({
|
||||
...jsonError(429, 'Rate limit exceeded'),
|
||||
retryAfter: '10',
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
req.once?.('aborted', abort);
|
||||
res.once?.('close', abort);
|
||||
try {
|
||||
const answer = await features(
|
||||
`${CACHE_VERSION}|features|${upstreamTag()}|${compiled.ql}`,
|
||||
compiled.ql,
|
||||
(parsed) => summarizeOsmFeatures(parsed, compiled),
|
||||
controller.signal,
|
||||
client,
|
||||
);
|
||||
if (!controller.signal.aborted) send(answer);
|
||||
} catch {
|
||||
if (!controller.signal.aborted)
|
||||
send(jsonError(502, 'OpenStreetMap search failed.'));
|
||||
} finally {
|
||||
req.removeListener?.('aborted', abort);
|
||||
res.removeListener?.('close', abort);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -122,12 +122,14 @@ function sanitizeOverpassBody(rawBody) {
|
||||
const boundedSets = new Set();
|
||||
for (let stmt of withoutTags.split(';')) {
|
||||
stmt = stmt.trim();
|
||||
if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue;
|
||||
// The settings block (`[out:json][timeout:25]`) was blanked together with
|
||||
// the tag filters, so it arrives here as an empty statement.
|
||||
if (!stmt || /^out\b/.test(stmt)) continue;
|
||||
|
||||
// Strip output-set assignments (NOT input bounds). Bracket tag filters were
|
||||
// removed above so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot be
|
||||
// misread as a spatial bound. Bounds live in (...) / function calls / set
|
||||
// refs, never inside [...], so the probe loses nothing real.
|
||||
// Strip output-set assignments (NOT input bounds). With the tag filters
|
||||
// already gone, a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot
|
||||
// be misread as a spatial bound; bounds live in (...) / function calls /
|
||||
// set refs, never inside [...], so nothing real was lost.
|
||||
const outSets = [];
|
||||
const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
|
||||
outSets.push(name);
|
||||
@@ -166,8 +168,10 @@ function sanitizeOverpassBody(rawBody) {
|
||||
if (bounded) for (const name of outSets) boundedSets.add(name);
|
||||
}
|
||||
|
||||
// Tolerate whitespace around the directive so `[ timeout : N ]` is clamped
|
||||
// rather than passed through as written.
|
||||
const clamped = data.replace(
|
||||
/\[timeout:\s*(\d+)\s*\]/gi,
|
||||
/\[\s*timeout\s*:\s*(\d+)\s*\]/gi,
|
||||
(_, n) =>
|
||||
`[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`,
|
||||
);
|
||||
|
||||
@@ -3,10 +3,24 @@ import assert from 'node:assert/strict';
|
||||
import { Readable } from 'node:stream';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { overpassProxy } from '../overpass.js';
|
||||
import { _overpassCache } from './cache.js';
|
||||
import { sanitizeOverpassBody } from './query.js';
|
||||
|
||||
const form = (query) => `data=${encodeURIComponent(query)}`;
|
||||
|
||||
// The limiters are module-level, so every test that spends admission mocks a
|
||||
// clock a full day past the previous one in execution order: no test can see
|
||||
// another's window, whatever order (or randomization) they run in. A new
|
||||
// test that reaches admission must call it too: earlier tests leave
|
||||
// timestamps days ahead of the real clock, and those still count.
|
||||
let clockBase = Date.now() + 86_400_000;
|
||||
function freshClock(t) {
|
||||
clockBase += 86_400_000;
|
||||
const clock = { now: clockBase };
|
||||
t.mock.method(Date, 'now', () => clock.now);
|
||||
return clock;
|
||||
}
|
||||
|
||||
test('bracket validation rejects malformed filters without trusting their contents as bounds', () => {
|
||||
for (const query of [
|
||||
'node[',
|
||||
@@ -37,6 +51,15 @@ test('bounded queries preserve quoted brackets, comments, multiple tags and time
|
||||
query.replace('[timeout:999]', '[timeout:30]'),
|
||||
);
|
||||
}
|
||||
// Whitespace inside the directive is clamped too, not passed through.
|
||||
const spaced = sanitizeOverpassBody(
|
||||
form('[out:json][ timeout : 999 ];node(around:20,1,1);out;'),
|
||||
);
|
||||
assert.equal(spaced.ok, true);
|
||||
assert.equal(
|
||||
new URLSearchParams(spaced.body).get('data'),
|
||||
'[out:json][timeout:30];node(around:20,1,1);out;',
|
||||
);
|
||||
});
|
||||
|
||||
test('a burst of maximum-size unterminated filters completes within a bounded process deadline', () => {
|
||||
@@ -126,7 +149,13 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
{ 'sec-fetch-site': 'cross-site' },
|
||||
{ 'sec-fetch-site': 'same-site' },
|
||||
{ origin: 'null' },
|
||||
{ 'x-forwarded-for': '127.0.0.1' },
|
||||
// Forwarding headers do not launder a cross-site request.
|
||||
{ 'x-forwarded-for': '10.0.0.9', 'sec-fetch-site': 'cross-site' },
|
||||
{
|
||||
'x-forwarded-proto': 'https',
|
||||
origin: 'http://localhost:4173',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
},
|
||||
]) {
|
||||
const response = await call(route, { headers, forbidRead: true });
|
||||
assert.equal(response.statusCode, 403);
|
||||
@@ -136,12 +165,24 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
});
|
||||
}
|
||||
|
||||
test('same-origin, LAN and non-browser requests still reach validation; GET status stays available', async () => {
|
||||
test('same-origin, LAN, proxied and non-browser requests still reach validation; GET status stays available', async (t) => {
|
||||
freshClock(t);
|
||||
const route = handler();
|
||||
for (const headers of [
|
||||
{},
|
||||
{ origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' },
|
||||
{ host: '192.168.1.10:4173', origin: 'http://192.168.1.10:4173' },
|
||||
// The README's reverse-proxy deployment: Overpass is keyless, so the
|
||||
// forwarding headers the proxy adds are admitted (as for terrain heights).
|
||||
{
|
||||
host: 'globe.example.lan',
|
||||
origin: 'https://globe.example.lan',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-forwarded-for': '203.0.113.7',
|
||||
'x-forwarded-proto': 'https',
|
||||
'x-forwarded-host': 'globe.example.lan',
|
||||
},
|
||||
{ 'x-forwarded-for': '127.0.0.1' },
|
||||
]) {
|
||||
const response = await call(route, { headers });
|
||||
assert.equal(response.statusCode, 400);
|
||||
@@ -158,8 +199,7 @@ test('same-origin, LAN and non-browser requests still reach validation; GET stat
|
||||
});
|
||||
|
||||
test('invalid requests spend admission quota before parsing, including on an unconfigured server', async (t) => {
|
||||
let now = Date.now() + 120000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const clock = freshClock(t);
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
@@ -173,6 +213,216 @@ test('invalid requests spend admission quota before parsing, including on an unc
|
||||
assert.equal(denied.reads, 0);
|
||||
assert.equal(denied.headers['retry-after'], '60');
|
||||
assert.equal((await call(route, { peer: 'other-peer' })).statusCode, 400);
|
||||
now += 60001;
|
||||
clock.now += 60_001;
|
||||
assert.equal((await call(route, { peer })).statusCode, 400);
|
||||
});
|
||||
|
||||
test('the 900/min global window spans non-loopback peers only; loopback keeps its own window', async (t) => {
|
||||
const clock = freshClock(t);
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
const route = handler();
|
||||
for (const peer of ['10.0.0.1', '10.0.0.2', '10.0.0.3']) {
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal((await call(route, { peer })).statusCode, 400, peer);
|
||||
}
|
||||
}
|
||||
const starved = await call(route, { peer: '10.0.0.4', forbidRead: true });
|
||||
assert.equal(starved.statusCode, 429, 'global window is full for LAN peers');
|
||||
assert.equal(starved.reads, 0);
|
||||
for (const peer of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) {
|
||||
const owner = await call(route, { peer });
|
||||
assert.equal(owner.statusCode, 400, `${peer} is admitted past the global`);
|
||||
assert.equal(owner.reads, 1);
|
||||
}
|
||||
clock.now += 60_001;
|
||||
assert.equal((await call(route, { peer: '10.0.0.4' })).statusCode, 400);
|
||||
});
|
||||
|
||||
test('IPv6 peers share a /64 admission bucket and IPv4-mapped peers fold to IPv4', async (t) => {
|
||||
const clock = freshClock(t);
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
const route = handler();
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal(
|
||||
(await call(route, { peer: `2001:db8:1:2::${(i % 200) + 1}` }))
|
||||
.statusCode,
|
||||
400,
|
||||
);
|
||||
}
|
||||
const rotated = await call(route, {
|
||||
peer: '2001:db8:1:2:ffff:ffff:ffff:9',
|
||||
forbidRead: true,
|
||||
});
|
||||
assert.equal(rotated.statusCode, 429, 'a rotating host stays in one bucket');
|
||||
assert.equal(
|
||||
(await call(route, { peer: '2001:db8:1:3::1' })).statusCode,
|
||||
400,
|
||||
);
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal(
|
||||
(await call(route, { peer: '::ffff:10.1.1.1' })).statusCode,
|
||||
400,
|
||||
);
|
||||
}
|
||||
const unmapped = await call(route, { peer: '10.1.1.1', forbidRead: true });
|
||||
assert.equal(unmapped.statusCode, 429, 'mapped and bare IPv4 share a bucket');
|
||||
clock.now += 60_001;
|
||||
assert.equal((await call(route, { peer: '10.1.1.1' })).statusCode, 400);
|
||||
});
|
||||
|
||||
test('cache hits spend admission budget but not the 90/min upstream budget', async (t) => {
|
||||
const clock = freshClock(t);
|
||||
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
|
||||
t.after(() => {
|
||||
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
|
||||
else process.env.OVERPASS_UPSTREAMS = prior;
|
||||
_overpassCache.delete(hitKey);
|
||||
});
|
||||
let fetches = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
fetches++;
|
||||
// Not data: nothing is cached or written to disk by this test.
|
||||
return new Response('upstream unavailable', { status: 503 });
|
||||
});
|
||||
const hit = 'node(around:20,1,1);out;';
|
||||
const hitKey = `data=${encodeURIComponent(hit)}`;
|
||||
_overpassCache.set(hitKey, {
|
||||
status: 200,
|
||||
body: '{"elements":[]}',
|
||||
contentType: 'application/json',
|
||||
endpoint: 'https://overpass.example/api/interpreter',
|
||||
cachedAt: clock.now,
|
||||
});
|
||||
const route = handler();
|
||||
const peer = '10.7.7.7';
|
||||
// More cache hits than the upstream limiter would allow per peer.
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const response = await call(route, { peer, body: form(hit) });
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.headers['x-overpass-cache'], 'HIT');
|
||||
}
|
||||
assert.equal(fetches, 0);
|
||||
const miss = await call(route, {
|
||||
peer,
|
||||
body: form('node(around:20,2,2);out;'),
|
||||
});
|
||||
assert.equal(fetches, 1, 'the first miss still reaches upstream');
|
||||
assert.notEqual(miss.statusCode, 429);
|
||||
for (let i = 0; i < 199; i++) {
|
||||
assert.equal(
|
||||
(await call(route, { peer, body: form(hit) })).statusCode,
|
||||
200,
|
||||
);
|
||||
}
|
||||
const exhausted = await call(route, {
|
||||
peer,
|
||||
body: form(hit),
|
||||
forbidRead: true,
|
||||
});
|
||||
assert.equal(exhausted.statusCode, 429, 'hits spent the admission window');
|
||||
assert.equal(exhausted.reads, 0);
|
||||
});
|
||||
|
||||
test('a local reverse proxy cannot spend the direct-loopback reserve; owner cache hits survive it', async (t) => {
|
||||
const clock = freshClock(t);
|
||||
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
|
||||
const hit = 'node(around:20,3,3);out;';
|
||||
const hitKey = `data=${encodeURIComponent(hit)}`;
|
||||
t.after(() => {
|
||||
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
|
||||
else process.env.OVERPASS_UPSTREAMS = prior;
|
||||
_overpassCache.delete(hitKey);
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
_overpassCache.set(hitKey, {
|
||||
status: 200,
|
||||
body: '{"elements":[]}',
|
||||
contentType: 'application/json',
|
||||
endpoint: 'https://overpass.example/api/interpreter',
|
||||
cachedAt: clock.now,
|
||||
});
|
||||
const route = handler();
|
||||
// The proxy connects over loopback; a rotating forwarded client address
|
||||
// must not mint fresh buckets, so all 300 land in one socket-keyed bucket.
|
||||
for (const proxyPeer of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) {
|
||||
const proxied = (i) => ({
|
||||
peer: proxyPeer,
|
||||
headers: { 'x-forwarded-for': `203.0.113.${i % 250}` },
|
||||
body: form(hit),
|
||||
});
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal((await call(route, proxied(i))).statusCode, 200, proxyPeer);
|
||||
}
|
||||
const refused = await call(route, { ...proxied(300), forbidRead: true });
|
||||
assert.equal(refused.statusCode, 429, `${proxyPeer} proxy bucket is full`);
|
||||
assert.equal(refused.reads, 0);
|
||||
for (const peer of ['127.0.0.1', '::1']) {
|
||||
const owner = await call(route, { peer, body: form(hit) });
|
||||
assert.equal(owner.statusCode, 200, `direct ${peer} is still admitted`);
|
||||
assert.equal(owner.headers['x-overpass-cache'], 'HIT');
|
||||
}
|
||||
clock.now += 60_001;
|
||||
assert.equal((await call(route, proxied(0))).statusCode, 200);
|
||||
}
|
||||
});
|
||||
|
||||
test('proxied loopback traffic draws on the shared remote global window', async (t) => {
|
||||
const clock = freshClock(t);
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
const route = handler();
|
||||
for (const peer of ['10.0.0.1', '10.0.0.2']) {
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal((await call(route, { peer })).statusCode, 400, peer);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal(
|
||||
(await call(route, { headers: { via: '1.1 proxy' } })).statusCode,
|
||||
400,
|
||||
);
|
||||
}
|
||||
const starved = await call(route, { peer: '10.0.0.3', forbidRead: true });
|
||||
assert.equal(starved.statusCode, 429, 'proxied requests counted globally');
|
||||
assert.equal((await call(route, {})).statusCode, 400, 'owner unaffected');
|
||||
});
|
||||
|
||||
test("a local proxy has its own upstream bucket, so its misses cannot spend the owner's", async (t) => {
|
||||
freshClock(t);
|
||||
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
|
||||
t.after(() => {
|
||||
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
|
||||
else process.env.OVERPASS_UPSTREAMS = prior;
|
||||
});
|
||||
// Not data: nothing is cached, so every request below is a miss.
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('upstream unavailable', { status: 503 }),
|
||||
);
|
||||
const route = handler();
|
||||
const miss = form('node(around:20,4,4);out;');
|
||||
// The upstream limiter's own refusal is the only miss without this header.
|
||||
const passedLimiter = (response) =>
|
||||
response.headers['x-overpass-cache'] !== undefined;
|
||||
const proxied = { peer: '127.0.0.1', headers: { via: '1.1 proxy' } };
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const response = await call(route, { ...proxied, body: miss });
|
||||
assert.equal(passedLimiter(response), true, `proxied miss ${i}`);
|
||||
}
|
||||
const spent = await call(route, { ...proxied, body: miss });
|
||||
assert.equal(spent.statusCode, 429);
|
||||
assert.equal(passedLimiter(spent), false, 'the proxy bucket is spent');
|
||||
const owner = await call(route, { peer: '127.0.0.1', body: miss });
|
||||
assert.equal(passedLimiter(owner), true, 'the owner keeps its own bucket');
|
||||
});
|
||||
@@ -124,6 +124,8 @@ async function fetchOverpassPayload(
|
||||
readBody = readResponseTextCapped,
|
||||
simplify = simplifyOverpassPayloadBody,
|
||||
now = Date.now,
|
||||
timeoutMs = OVERPASS_TIMEOUT_MS,
|
||||
signal = null,
|
||||
} = {},
|
||||
) {
|
||||
if (!endpoints.length) return overpassNotConfigured();
|
||||
@@ -135,7 +137,10 @@ async function fetchOverpassPayload(
|
||||
continue;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), OVERPASS_TIMEOUT_MS);
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const requestSignal = signal
|
||||
? AbortSignal.any([signal, controller.signal])
|
||||
: controller.signal;
|
||||
try {
|
||||
const requestUrl = new URL(endpoint);
|
||||
const authorization =
|
||||
@@ -156,7 +161,7 @@ async function fetchOverpassPayload(
|
||||
...(authorization ? { Authorization: authorization } : {}),
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
signal: requestSignal,
|
||||
});
|
||||
const responseBody = await readBody(upstream, maxResponseBytes);
|
||||
const parsed = parseJson(responseBody);
|
||||
@@ -207,7 +212,12 @@ async function fetchOverpassPayload(
|
||||
upstreams: resolveOverpassUpstreamTag(),
|
||||
rateLimited: false,
|
||||
};
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// A route whose last requester disconnected owns this cancellation. It
|
||||
// is not evidence that the configured upstream is unhealthy, so do not
|
||||
// put the endpoint on cooldown or translate the abort into a response
|
||||
// that a now-gone client could cache.
|
||||
if (signal?.aborted) throw signal.reason ?? error;
|
||||
const failures = (previous?.failures || 0) + 1;
|
||||
const delay = retryDelay(null, failures - 1, now());
|
||||
cooldowns.set(endpoint, { until: now() + delay, failures, status: 502 });
|
||||
|
||||
@@ -7,9 +7,13 @@ export {
|
||||
publicRadioHttpsUrl,
|
||||
} from './radio/stations.js';
|
||||
export function radioBrowserProxy() {
|
||||
const middleware = createRadioProxyMiddleware();
|
||||
let middleware;
|
||||
const initialize = () =>
|
||||
(middleware ||= createRadioProxyMiddleware({
|
||||
priorityCountries: process.env.RADIO_PRIORITY_COUNTRIES || '',
|
||||
}));
|
||||
const install = (server) => {
|
||||
server.middlewares.use('/api/radio', middleware);
|
||||
server.middlewares.use('/api/radio', initialize());
|
||||
};
|
||||
return {
|
||||
name: 'radio-browser-proxy',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeRadioCountryInput } from '../../../src/data/radioCountry.js';
|
||||
import { lookup as lookupDns } from 'node:dns/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
@@ -27,6 +28,34 @@ 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;
|
||||
@@ -49,7 +78,9 @@ 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;
|
||||
@@ -61,6 +92,9 @@ 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);
|
||||
@@ -139,7 +173,7 @@ export function createRadioProxyMiddleware({
|
||||
|
||||
async function fetchPath(pathname) {
|
||||
let lastError = null;
|
||||
for (const origin of await mirrors()) {
|
||||
for (const origin of (await mirrors()).slice(0, MAX_MIRROR_ATTEMPTS)) {
|
||||
try {
|
||||
return await fetchJson(`${origin}${pathname}`);
|
||||
} catch (error) {
|
||||
@@ -160,20 +194,27 @@ 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, index) => {
|
||||
async ({ tag, country }, index) => {
|
||||
const params = new URLSearchParams({
|
||||
has_geo_info: 'true',
|
||||
is_https: 'true',
|
||||
hidebroken: 'true',
|
||||
order: 'clickcount',
|
||||
reverse: 'true',
|
||||
limit: index === 0 ? '1800' : '220',
|
||||
limit: country
|
||||
? String(COUNTRY_QUERY_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))
|
||||
@@ -195,7 +236,12 @@ export function createRadioProxyMiddleware({
|
||||
const rejectedRows = rows.length - structuralRows.length;
|
||||
const stations = structuralRows
|
||||
.map(normalizeRadioBrowserStation)
|
||||
.filter(Boolean);
|
||||
.filter(
|
||||
(station) =>
|
||||
station && (!country || station.countryCode === country),
|
||||
);
|
||||
if (country && rows.length > COUNTRY_QUERY_LIMIT)
|
||||
throw new Error('Radio Browser exceeded the country query limit');
|
||||
const requestedTag = normalizeRadioTag(tag);
|
||||
const requestedTagCovered =
|
||||
!requestedTag ||
|
||||
@@ -207,10 +253,12 @@ export function createRadioProxyMiddleware({
|
||||
),
|
||||
);
|
||||
return {
|
||||
// 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,
|
||||
// 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,
|
||||
stations,
|
||||
rejectedRows,
|
||||
};
|
||||
@@ -233,9 +281,24 @@ 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)) rows.slice(0, 45).forEach(take);
|
||||
for (const rows of resultSets.slice(1, baseQueryCount))
|
||||
rows.slice(0, 45).forEach(take);
|
||||
resultSets
|
||||
.flat()
|
||||
.sort(
|
||||
@@ -243,9 +306,9 @@ export function createRadioProxyMiddleware({
|
||||
)
|
||||
.forEach(take);
|
||||
const timestamp = now();
|
||||
const successfulQueries = outcomes.filter(
|
||||
(outcome) => outcome.succeeded,
|
||||
).length;
|
||||
const successfulQueries = outcomes
|
||||
.slice(0, baseQueryCount)
|
||||
.filter((outcome) => outcome.succeeded).length;
|
||||
const broadQueryHealthy =
|
||||
outcomes[0].succeeded && outcomes[0].stations.length > 0;
|
||||
const healthReasons = [];
|
||||
@@ -261,7 +324,18 @@ export function createRadioProxyMiddleware({
|
||||
);
|
||||
const coverage = {
|
||||
successfulQueries,
|
||||
totalQueries: queries.length,
|
||||
totalQueries: baseQueryCount,
|
||||
...(countries.length
|
||||
? {
|
||||
priorityCountries: countries.map((country, index) => ({
|
||||
countryCode: country,
|
||||
succeeded: outcomes[baseQueryCount + index].succeeded,
|
||||
stationCount: selected.filter(
|
||||
(station) => station.countryCode === country,
|
||||
).length,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
stationCount: selected.length,
|
||||
healthyStationMinimum: RADIO_CATALOG_HEALTHY_MIN_STATIONS,
|
||||
};
|
||||
@@ -315,7 +389,24 @@ export function createRadioProxyMiddleware({
|
||||
return { ...catalogCache, stale: false };
|
||||
}
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = refreshCatalog().finally(() => {
|
||||
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 = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -204,6 +204,97 @@ const ADMIN_EVIDENCE = Object.freeze({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Nominatim address types whose meaning is stable enough for the area consumer's
|
||||
* country/admin1/admin2 contract. `region`, `district` and numeric admin levels
|
||||
* vary by country, so they remain unverified instead of being guessed.
|
||||
*/
|
||||
const VERIFIED_ADMIN_LEVEL = Object.freeze({
|
||||
country: 'country',
|
||||
state: 'admin1',
|
||||
province: 'admin1',
|
||||
county: 'admin2',
|
||||
state_district: 'admin2',
|
||||
});
|
||||
|
||||
const EXPLICIT_NAME_LEVEL = Object.freeze({
|
||||
country: 'country',
|
||||
nation: 'country',
|
||||
province: 'admin1',
|
||||
state: 'admin1',
|
||||
region: 'admin1',
|
||||
oblast: 'admin1',
|
||||
prefecture: 'admin1',
|
||||
canton: 'admin1',
|
||||
governorate: 'admin1',
|
||||
county: 'admin2',
|
||||
district: 'admin2',
|
||||
department: 'admin2',
|
||||
zone: 'retired',
|
||||
});
|
||||
|
||||
function explicitNameEvidence(value) {
|
||||
const normalized = String(value || '')
|
||||
.normalize('NFKC')
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
|
||||
.trim();
|
||||
const prefix = normalized.match(
|
||||
/^(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)\s+of\b/,
|
||||
)?.[1];
|
||||
const suffix = normalized.match(
|
||||
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/,
|
||||
)?.[1];
|
||||
const levels = new Set(
|
||||
[prefix, suffix]
|
||||
.filter(Boolean)
|
||||
.map((type) => EXPLICIT_NAME_LEVEL[type])
|
||||
.filter(Boolean),
|
||||
);
|
||||
if (levels.size > 1) return { contradictory: true, level: null };
|
||||
return { contradictory: false, level: levels.values().next().value || null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verified administrative identity carried by the selected result itself.
|
||||
* Never infer it from the request, desired kind, rank, or a separate geocoder
|
||||
* anchor. A retired name is preserved without a level so downstream policy can
|
||||
* reject it explicitly; every other contradiction stays wholly unverified.
|
||||
*/
|
||||
function selectedAdminIdentity(kind, row) {
|
||||
if (kind !== 'admin') return null;
|
||||
const adminLevel =
|
||||
VERIFIED_ADMIN_LEVEL[String(row?.addresstype || '').toLowerCase()];
|
||||
const authoritativeAdminArea = String(row?.name || '').trim();
|
||||
if (!adminLevel || !authoritativeAdminArea) return null;
|
||||
// Validate the complete authoritative name. `adminArea` is truncated only
|
||||
// for display after every prefix/suffix signal has been considered.
|
||||
const { contradictory, level: nameLevel } = explicitNameEvidence(
|
||||
authoritativeAdminArea,
|
||||
);
|
||||
if (contradictory) return null;
|
||||
const adminArea = authoritativeAdminArea.slice(0, 160);
|
||||
if (nameLevel === 'retired') {
|
||||
// Nepal replaced its zones in 2015, but zones are current units elsewhere
|
||||
// (Ethiopia). Without a country the name stays retired.
|
||||
if (!zoneIsCurrentIn(row)) return { adminArea };
|
||||
return { adminArea, adminLevel };
|
||||
}
|
||||
if (nameLevel && nameLevel !== adminLevel) return null;
|
||||
return { adminArea, adminLevel };
|
||||
}
|
||||
|
||||
/** Whether the row names a country (last display_name part) other than Nepal. */
|
||||
function zoneIsCurrentIn(row) {
|
||||
const country = String(row?.display_name || '')
|
||||
.split(',')
|
||||
.slice(1)
|
||||
.pop()
|
||||
?.trim()
|
||||
.toLocaleLowerCase();
|
||||
return Boolean(country) && country !== 'nepal';
|
||||
}
|
||||
|
||||
/** Whether supplied administrative evidence contradicts the accepted identity. */
|
||||
function adminLevelFits(kind, row) {
|
||||
const evidence = ADMIN_EVIDENCE[kind];
|
||||
@@ -584,6 +675,7 @@ export function selectOutlineResult(rows, { kind, query }) {
|
||||
row.osm_type && row.osm_id != null
|
||||
? `${row.osm_type}/${row.osm_id}`
|
||||
: null,
|
||||
...(selectedAdminIdentity(kind, row) || {}),
|
||||
polygons,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -406,6 +406,8 @@ export function createAnnotationEngine({
|
||||
* @param {boolean} [opts.clearPrevious]
|
||||
* @param {boolean} [opts.persist] Keep until cleared (default true).
|
||||
* @param {boolean} [opts.flyTo] Frame the first resolved annotation.
|
||||
* @param {AbortSignal} [opts.signal] Caller lifetime for this mutation.
|
||||
* @param {() => boolean} [opts.isCurrent] Whether the caller still owns the board mutation.
|
||||
* @returns {Promise<{ok, drawn, failed, ids, results}>}
|
||||
*/
|
||||
async function annotate(requests, opts = {}) {
|
||||
@@ -426,9 +428,15 @@ export function createAnnotationEngine({
|
||||
const persist = opts.persist !== false;
|
||||
// Per-call cancellation: bumped/aborted by any later clear() or destroy().
|
||||
const controller = new AbortController();
|
||||
const abortFromCaller = () => controller.abort(opts.signal?.reason);
|
||||
opts.signal?.addEventListener?.('abort', abortFromCaller, { once: true });
|
||||
if (opts.signal?.aborted) abortFromCaller();
|
||||
retainController(controller);
|
||||
const myGen = generation;
|
||||
const superseded = () => myGen !== generation || controller.signal.aborted;
|
||||
const superseded = () =>
|
||||
myGen !== generation ||
|
||||
controller.signal.aborted ||
|
||||
(typeof opts.isCurrent === 'function' && !opts.isCurrent());
|
||||
|
||||
const results = [];
|
||||
const ids = [];
|
||||
@@ -847,6 +855,7 @@ export function createAnnotationEngine({
|
||||
firstAnchor = annotations.get(ids[0]) || null;
|
||||
}
|
||||
} finally {
|
||||
opts.signal?.removeEventListener?.('abort', abortFromCaller);
|
||||
releaseController(controller);
|
||||
}
|
||||
|
||||
@@ -1520,6 +1529,18 @@ export function createAnnotationEngine({
|
||||
// Every part with its holes ([outer, ...holes][]), when the outline has
|
||||
// more than the main ring (Hawaii's islands, Berlin inside Brandenburg).
|
||||
polygons: resolved.polygons || null,
|
||||
// A voice area handle's id: counting reads the handle's full geometry,
|
||||
// never this drawing (see voice/areaActions.js).
|
||||
areaId:
|
||||
typeof spec?.areaId === 'string' ? spec.areaId.slice(0, 120) : null,
|
||||
// Who made the outline: the draw tool, a voice area handle, or a voice
|
||||
// annotation resolved from a name.
|
||||
origin:
|
||||
spec?.manual === true
|
||||
? spec.origin === 'area'
|
||||
? 'area'
|
||||
: 'drawn'
|
||||
: 'voice',
|
||||
footprintKind: resolved.footprintKind || null, // 'building' | 'area'
|
||||
outlineCredit: resolved.outlineCredit || null,
|
||||
buildingHeight: resolved.buildingHeight || null, // meters, for extruded volume
|
||||
@@ -1809,6 +1830,12 @@ export function createAnnotationEngine({
|
||||
},
|
||||
fadeOutAll,
|
||||
count: () => annotations.size,
|
||||
/**
|
||||
* The board generation: advanced by every clear. A caller that resolves
|
||||
* something before drawing captures it first and draws only while it is
|
||||
* unchanged, so a clear during a slow lookup is never undone.
|
||||
*/
|
||||
generation: () => generation,
|
||||
list: () => Array.from(annotations.values()),
|
||||
|
||||
/**
|
||||
@@ -2200,13 +2227,40 @@ function isManualSpec(spec, type) {
|
||||
if (!spec || spec.manual !== true) return false;
|
||||
if (type === 'route')
|
||||
return Array.isArray(spec.path) && spec.path.length >= 2;
|
||||
if (type === 'area') return Array.isArray(spec.ring) && spec.ring.length >= 3;
|
||||
if (type === 'area')
|
||||
return (
|
||||
(Array.isArray(spec.ring) && spec.ring.length >= 3) ||
|
||||
(spec.origin === 'area' && Array.isArray(spec.polygons))
|
||||
);
|
||||
return (
|
||||
Number.isFinite(Number(spec.latitude)) &&
|
||||
Number.isFinite(Number(spec.longitude))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parts of a voice area handle, `[[outer, ...holes], …]` of [lon, lat] pairs.
|
||||
* Longitudes may run to ±540 (a part unwrapped across the antimeridian draws
|
||||
* as one shape). Null when no part has a usable outer ring.
|
||||
*/
|
||||
function areaPolygons(list) {
|
||||
const ringOf = (ring) =>
|
||||
(Array.isArray(ring) ? ring : []).filter(
|
||||
(p) =>
|
||||
Array.isArray(p) &&
|
||||
Number.isFinite(p[0]) &&
|
||||
Number.isFinite(p[1]) &&
|
||||
Math.abs(p[0]) <= 540 &&
|
||||
Math.abs(p[1]) <= 90,
|
||||
);
|
||||
const polygons = (Array.isArray(list) ? list : [])
|
||||
.slice(0, 64)
|
||||
.map((rings) => (Array.isArray(rings) ? rings.map(ringOf) : []))
|
||||
.filter((rings) => rings[0]?.length >= 3)
|
||||
.map(([outer, ...holes]) => [outer, ...holes.filter((h) => h.length >= 3)]);
|
||||
return polygons.length ? polygons : null;
|
||||
}
|
||||
|
||||
/** [lon, lat] pairs or {lon, lat} objects → [lon, lat] pairs, invalid entries dropped. */
|
||||
function manualPairs(list) {
|
||||
return (Array.isArray(list) ? list : [])
|
||||
@@ -2253,7 +2307,9 @@ function resolveManualSpec(spec, type, viewer) {
|
||||
};
|
||||
}
|
||||
if (type === 'area') {
|
||||
const ring = manualPairs(spec.ring);
|
||||
const polygons =
|
||||
spec.origin === 'area' ? areaPolygons(spec.polygons) : null;
|
||||
const ring = polygons ? polygons[0][0] : manualPairs(spec.ring);
|
||||
if (ring.length < 3)
|
||||
throw new Error('a drawn area needs at least 3 points');
|
||||
// Averaged through ringCentroid, which unwraps longitudes first: a ring
|
||||
@@ -2270,9 +2326,11 @@ function resolveManualSpec(spec, type, viewer) {
|
||||
lat,
|
||||
height: sampleGroundHeight(viewer, lon, lat),
|
||||
ring,
|
||||
...(polygons ? { polygons } : {}),
|
||||
footprintKind: 'area',
|
||||
buildingHeight: null,
|
||||
synthesized: false,
|
||||
// An approximate area (a buffer around a landmark) draws dashed.
|
||||
synthesized: spec.approximate === true,
|
||||
source: 'manual',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,11 +43,21 @@ function scriptedResolver(outcomes) {
|
||||
// Instant fake wait that records the requested backoff delays.
|
||||
function fakeWait() {
|
||||
const delays = [];
|
||||
const waitFn = async (ms) => { delays.push(ms); };
|
||||
const waitFn = async (ms) => {
|
||||
delays.push(ms);
|
||||
};
|
||||
return { waitFn, delays };
|
||||
}
|
||||
|
||||
const FP = { ring: [[0, 0], [0, 1], [1, 1], [0, 0]], footprintKind: 'area' };
|
||||
const FP = {
|
||||
ring: [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
],
|
||||
footprintKind: 'area',
|
||||
};
|
||||
|
||||
function installAnimationFrameStubs(t) {
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
@@ -55,9 +65,11 @@ function installAnimationFrameStubs(t) {
|
||||
globalThis.requestAnimationFrame = () => 1;
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
t.after(() => {
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
if (originalRequestAnimationFrame === undefined)
|
||||
delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
if (originalCancelAnimationFrame === undefined) delete globalThis.cancelAnimationFrame;
|
||||
if (originalCancelAnimationFrame === undefined)
|
||||
delete globalThis.cancelAnimationFrame;
|
||||
else globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||
});
|
||||
}
|
||||
@@ -67,9 +79,15 @@ function fakeRenderer() {
|
||||
return {
|
||||
calls,
|
||||
renderer: {
|
||||
add() { calls.add += 1; },
|
||||
update() { calls.update += 1; },
|
||||
remove() { calls.remove += 1; },
|
||||
add() {
|
||||
calls.add += 1;
|
||||
},
|
||||
update() {
|
||||
calls.update += 1;
|
||||
},
|
||||
remove() {
|
||||
calls.remove += 1;
|
||||
},
|
||||
sync() {},
|
||||
},
|
||||
};
|
||||
@@ -83,14 +101,19 @@ function httpFailure(status, retryAfter = null) {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
headers: { get: (name) => (name.toLowerCase() === 'retry-after' ? retryAfter : null) },
|
||||
headers: {
|
||||
get: (name) => (name.toLowerCase() === 'retry-after' ? retryAfter : null),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('retry: first-try footprint returns immediately, no retry, no waiting', async () => {
|
||||
const { resolve, calls } = scriptedResolver([FP]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
const fp = await resolveOutlineWithRetry(resolve, {
|
||||
delaysMs: [8000, 25000],
|
||||
waitFn,
|
||||
});
|
||||
assert.equal(fp, FP);
|
||||
assert.equal(calls.count, 1);
|
||||
assert.deepEqual(delays, []);
|
||||
@@ -99,7 +122,10 @@ test('retry: first-try footprint returns immediately, no retry, no waiting', asy
|
||||
test('retry: a DEFINITIVE miss (null) is never retried — the honest point stands', async () => {
|
||||
const { resolve, calls } = scriptedResolver([null, FP]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
const fp = await resolveOutlineWithRetry(resolve, {
|
||||
delaysMs: [8000, 25000],
|
||||
waitFn,
|
||||
});
|
||||
assert.equal(fp, null);
|
||||
assert.equal(calls.count, 1);
|
||||
assert.deepEqual(delays, []);
|
||||
@@ -108,7 +134,10 @@ test('retry: a DEFINITIVE miss (null) is never retried — the honest point stan
|
||||
test('retry: a TRANSIENT miss (undefined) re-runs after the first backoff and can succeed', async () => {
|
||||
const { resolve, calls } = scriptedResolver([undefined, FP]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
const fp = await resolveOutlineWithRetry(resolve, {
|
||||
delaysMs: [8000, 25000],
|
||||
waitFn,
|
||||
});
|
||||
assert.equal(fp, FP);
|
||||
assert.equal(calls.count, 2);
|
||||
assert.deepEqual(delays, [8000]); // waited once, succeeded on attempt 2
|
||||
@@ -117,16 +146,27 @@ test('retry: a TRANSIENT miss (undefined) re-runs after the first backoff and ca
|
||||
test('retry: a transient retry can still conclude with a definitive miss (no third run)', async () => {
|
||||
const { resolve, calls } = scriptedResolver([undefined, null]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
const fp = await resolveOutlineWithRetry(resolve, {
|
||||
delaysMs: [8000, 25000],
|
||||
waitFn,
|
||||
});
|
||||
assert.equal(fp, null);
|
||||
assert.equal(calls.count, 2);
|
||||
assert.deepEqual(delays, [8000]);
|
||||
});
|
||||
|
||||
test('retry: persistent transients exhaust the backoff schedule and give up as transient', async () => {
|
||||
const { resolve, calls } = scriptedResolver([undefined, undefined, undefined, FP]);
|
||||
const { resolve, calls } = scriptedResolver([
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
FP,
|
||||
]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
const fp = await resolveOutlineWithRetry(resolve, {
|
||||
delaysMs: [8000, 25000],
|
||||
waitFn,
|
||||
});
|
||||
assert.equal(fp, undefined); // never found within budget — mark honestly stays a point
|
||||
assert.equal(calls.count, 3); // initial + one per backoff entry
|
||||
assert.deepEqual(delays, [8000, 25000]);
|
||||
@@ -138,7 +178,10 @@ test('retry: a stale board (clear/supersede during backoff) stops retrying immed
|
||||
let stale = false;
|
||||
const fp = await resolveOutlineWithRetry(resolve, {
|
||||
delaysMs: [8000, 25000],
|
||||
waitFn: async (ms) => { await waitFn(ms); stale = true; }, // goes stale mid-wait
|
||||
waitFn: async (ms) => {
|
||||
await waitFn(ms);
|
||||
stale = true;
|
||||
}, // goes stale mid-wait
|
||||
isStale: () => stale,
|
||||
});
|
||||
assert.equal(fp, undefined);
|
||||
@@ -149,7 +192,10 @@ test('retry: a stale board (clear/supersede during backoff) stops retrying immed
|
||||
test('retry: a thrown resolver is a definitive miss (no retry), matching the old catch→point path', async () => {
|
||||
const { resolve, calls } = scriptedResolver([new Error('boom'), FP]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
const fp = await resolveOutlineWithRetry(resolve, {
|
||||
delaysMs: [8000, 25000],
|
||||
waitFn,
|
||||
});
|
||||
assert.equal(fp, null);
|
||||
assert.equal(calls.count, 1);
|
||||
assert.deepEqual(delays, []);
|
||||
@@ -171,43 +217,65 @@ test('outline queue: an 8-spec batch keeps at most two FIFO upgrades in flight',
|
||||
label: target,
|
||||
source: 'fake',
|
||||
ring: null,
|
||||
resolveOutline: () => new Promise((resolve) => {
|
||||
started.push(target);
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
releases.set(target, () => {
|
||||
inFlight -= 1;
|
||||
resolve({
|
||||
ring: [[index, index], [index + 0.1, index], [index, index + 0.1], [index, index]],
|
||||
footprintKind: 'area',
|
||||
lat: index,
|
||||
lon: index,
|
||||
height: 0,
|
||||
resolveOutline: () =>
|
||||
new Promise((resolve) => {
|
||||
started.push(target);
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
releases.set(target, () => {
|
||||
inFlight -= 1;
|
||||
resolve({
|
||||
ring: [
|
||||
[index, index],
|
||||
[index + 0.1, index],
|
||||
[index, index + 0.1],
|
||||
[index, index],
|
||||
],
|
||||
footprintKind: 'area',
|
||||
lat: index,
|
||||
lon: index,
|
||||
height: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
}),
|
||||
}),
|
||||
};
|
||||
};
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget });
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget,
|
||||
});
|
||||
let completed = 0;
|
||||
const allCompleted = new Promise((resolve) => engine.onOutlineEvent(() => {
|
||||
completed += 1;
|
||||
if (completed === 8) resolve();
|
||||
}));
|
||||
const allCompleted = new Promise((resolve) =>
|
||||
engine.onOutlineEvent(() => {
|
||||
completed += 1;
|
||||
if (completed === 8) resolve();
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await engine.annotate(Array.from({ length: 8 }, (_, i) => ({
|
||||
type: 'area',
|
||||
target: `queue-${i}`,
|
||||
footprint: true,
|
||||
})));
|
||||
const result = await engine.annotate(
|
||||
Array.from({ length: 8 }, (_, i) => ({
|
||||
type: 'area',
|
||||
target: `queue-${i}`,
|
||||
footprint: true,
|
||||
})),
|
||||
);
|
||||
|
||||
assert.equal(result.drawn, 8);
|
||||
assert.equal(calls.add, 8, 'all point renders land before queued outlines finish');
|
||||
assert.equal(
|
||||
calls.add,
|
||||
8,
|
||||
'all point renders land before queued outlines finish',
|
||||
);
|
||||
assert.deepEqual(started, ['queue-0', 'queue-1']);
|
||||
for (let next = 2; next < 8; next += 1) {
|
||||
releases.get(`queue-${next - 2}`)();
|
||||
await flushMicrotasks();
|
||||
assert.equal(started[next], `queue-${next}`, 'queued upgrades start in ask order');
|
||||
assert.equal(
|
||||
started[next],
|
||||
`queue-${next}`,
|
||||
'queued upgrades start in ask order',
|
||||
);
|
||||
}
|
||||
releases.get('queue-6')();
|
||||
releases.get('queue-7')();
|
||||
@@ -292,7 +360,10 @@ test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429
|
||||
const originalWindow = globalThis.window;
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 10_000 });
|
||||
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
|
||||
globalThis.window = {
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
};
|
||||
const requestTimes = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
// The one-time capability probe is not an outline request.
|
||||
@@ -323,11 +394,16 @@ test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429
|
||||
waits.push(ms);
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
},
|
||||
})
|
||||
.finally(() => { settled = true; });
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
while (waits.length === 0) await flushMicrotasks();
|
||||
assert.equal(requestTimes.length, 1);
|
||||
assert.deepEqual(waits, [8000], 'the 8s ladder floor is longer than Retry-After: 5');
|
||||
assert.deepEqual(
|
||||
waits,
|
||||
[8000],
|
||||
'the 8s ladder floor is longer than Retry-After: 5',
|
||||
);
|
||||
|
||||
t.mock.timers.tick(7999);
|
||||
await flushMicrotasks();
|
||||
@@ -342,8 +418,15 @@ test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429
|
||||
}
|
||||
|
||||
assert.equal(requestTimes.length, 2);
|
||||
assert.ok(requestTimes[1] - requestTimes[0] >= 5000, 'Retry-After is a hard minimum');
|
||||
assert.equal(settled, true, 'a second 429 must not schedule the 25s ladder replay');
|
||||
assert.ok(
|
||||
requestTimes[1] - requestTimes[0] >= 5000,
|
||||
'Retry-After is a hard minimum',
|
||||
);
|
||||
assert.equal(
|
||||
settled,
|
||||
true,
|
||||
'a second 429 must not schedule the 25s ladder replay',
|
||||
);
|
||||
assert.equal(await pending, undefined);
|
||||
});
|
||||
|
||||
@@ -351,7 +434,10 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
|
||||
const originalWindow = globalThis.window;
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 });
|
||||
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
|
||||
globalThis.window = {
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
};
|
||||
const requestTimes = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).endsWith('/status'))
|
||||
@@ -382,8 +468,9 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
|
||||
waits.push(ms);
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
},
|
||||
})
|
||||
.finally(() => { settled = true; });
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
while (waits.length === 0) await flushMicrotasks();
|
||||
assert.equal(requestTimes.length, 1);
|
||||
assert.deepEqual(waits, [8000]);
|
||||
@@ -400,7 +487,10 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(requestTimes.length, 3);
|
||||
assert.deepEqual(requestTimes.map((at) => at - requestTimes[0]), [0, 8000, 33_000]);
|
||||
assert.deepEqual(
|
||||
requestTimes.map((at) => at - requestTimes[0]),
|
||||
[0, 8000, 33_000],
|
||||
);
|
||||
assert.equal(settled, true);
|
||||
assert.equal(await pending, undefined);
|
||||
});
|
||||
@@ -415,7 +505,10 @@ test('targetKey: trailing locality qualifiers are stripped so state names dedupe
|
||||
});
|
||||
|
||||
test('targetKey: lowercases, trims, and keeps comma-free names intact', () => {
|
||||
assert.equal(normalizeTargetKey(' Texas State Capitol '), 'texas state capitol');
|
||||
assert.equal(
|
||||
normalizeTargetKey(' Texas State Capitol '),
|
||||
'texas state capitol',
|
||||
);
|
||||
assert.equal(normalizeTargetKey('Lady Bird Lake'), 'lady bird lake');
|
||||
});
|
||||
|
||||
@@ -1031,7 +1124,9 @@ test('duplicate-replacement: a renderer throw leaves no phantom mark and no leak
|
||||
resolveTarget: async () => ({ ...FIXED_POINT }),
|
||||
});
|
||||
|
||||
const first = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
|
||||
const first = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol' },
|
||||
]);
|
||||
assert.equal(first.drawn, 1);
|
||||
assert.equal(engine.list().length, 1);
|
||||
assert.ok(
|
||||
@@ -1046,12 +1141,25 @@ test('duplicate-replacement: a renderer throw leaves no phantom mark and no leak
|
||||
// branch for a point; see findDuplicate.)
|
||||
failAddOnce();
|
||||
const second = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol', color: 'amber' },
|
||||
{
|
||||
type: 'point',
|
||||
target: 'Texas Capitol',
|
||||
label: 'Capitol',
|
||||
color: 'amber',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(second.drawn, 0, 'the failed swap is reported as a failure, not a draw');
|
||||
assert.equal(
|
||||
second.drawn,
|
||||
0,
|
||||
'the failed swap is reported as a failure, not a draw',
|
||||
);
|
||||
assert.equal(second.failed, 1);
|
||||
assert.equal(engine.list().length, 0, 'no phantom annotation survives the failed swap');
|
||||
assert.equal(
|
||||
engine.list().length,
|
||||
0,
|
||||
'no phantom annotation survives the failed swap',
|
||||
);
|
||||
assert.ok(
|
||||
!getRenderGovernorDiagnostics().holds.includes('annotations'),
|
||||
'the annotations hold must be released — a leak defeats the idle governor permanently',
|
||||
@@ -1071,10 +1179,16 @@ test('fresh path: a renderer throw is rolled back the same way', async (t) => {
|
||||
});
|
||||
|
||||
failAddOnce();
|
||||
const result = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
|
||||
const result = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol' },
|
||||
]);
|
||||
|
||||
assert.equal(result.drawn, 0);
|
||||
assert.equal(engine.list().length, 0, 'no phantom annotation from a failed first add');
|
||||
assert.equal(
|
||||
engine.list().length,
|
||||
0,
|
||||
'no phantom annotation from a failed first add',
|
||||
);
|
||||
assert.ok(
|
||||
!getRenderGovernorDiagnostics().holds.includes('annotations'),
|
||||
'a never-rendered mark must not hold continuous render',
|
||||
@@ -1096,14 +1210,21 @@ function partialStateRendererHarness() {
|
||||
let failNextAdd = false;
|
||||
return {
|
||||
live,
|
||||
failAddOnce() { failNextAdd = true; },
|
||||
failAddOnce() {
|
||||
failNextAdd = true;
|
||||
},
|
||||
renderer: {
|
||||
add(anno) {
|
||||
live.set(anno.id, anno); // partial renderer state exists NOW
|
||||
if (failNextAdd) { failNextAdd = false; throw new Error('WebGL context lost'); }
|
||||
if (failNextAdd) {
|
||||
failNextAdd = false;
|
||||
throw new Error('WebGL context lost');
|
||||
}
|
||||
},
|
||||
update() {},
|
||||
remove(anno) { live.delete(anno.id); },
|
||||
remove(anno) {
|
||||
live.delete(anno.id);
|
||||
},
|
||||
sync() {},
|
||||
},
|
||||
};
|
||||
@@ -1122,10 +1243,16 @@ test('fresh path: a throw AFTER partial renderer state leaves nothing on the boa
|
||||
});
|
||||
|
||||
failAddOnce();
|
||||
const failed = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
|
||||
const failed = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol' },
|
||||
]);
|
||||
assert.equal(failed.drawn, 0);
|
||||
assert.equal(engine.list().length, 0, 'no phantom annotation');
|
||||
assert.equal(live.size, 0, 'the rollback must release what the renderer had already created');
|
||||
assert.equal(
|
||||
live.size,
|
||||
0,
|
||||
'the rollback must release what the renderer had already created',
|
||||
);
|
||||
assert.ok(
|
||||
!getRenderGovernorDiagnostics().holds.includes('annotations'),
|
||||
'a never-rendered mark must not hold continuous render',
|
||||
@@ -1133,9 +1260,15 @@ test('fresh path: a throw AFTER partial renderer state leaves nothing on the boa
|
||||
|
||||
// The real cost of an orphan: re-annotating the same place stacks a second
|
||||
// mark on top of the one nothing owns.
|
||||
const retry = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
|
||||
const retry = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol' },
|
||||
]);
|
||||
assert.equal(retry.drawn, 1);
|
||||
assert.equal(live.size, 1, 'a re-annotate must draw ONE mark, not stack over an orphan');
|
||||
assert.equal(
|
||||
live.size,
|
||||
1,
|
||||
'a re-annotate must draw ONE mark, not stack over an orphan',
|
||||
);
|
||||
});
|
||||
|
||||
test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing on the board', async (t) => {
|
||||
@@ -1150,7 +1283,9 @@ test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing
|
||||
resolveTarget: async () => ({ ...FIXED_POINT }),
|
||||
});
|
||||
|
||||
const first = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
|
||||
const first = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol' },
|
||||
]);
|
||||
assert.equal(first.drawn, 1);
|
||||
assert.equal(live.size, 1);
|
||||
|
||||
@@ -1158,10 +1293,19 @@ test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing
|
||||
// creates its state and then throws.
|
||||
failAddOnce();
|
||||
const swap = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol', color: 'amber' },
|
||||
{
|
||||
type: 'point',
|
||||
target: 'Texas Capitol',
|
||||
label: 'Capitol',
|
||||
color: 'amber',
|
||||
},
|
||||
]);
|
||||
assert.equal(swap.drawn, 0);
|
||||
assert.equal(engine.list().length, 0, 'no phantom annotation survives the failed swap');
|
||||
assert.equal(
|
||||
engine.list().length,
|
||||
0,
|
||||
'no phantom annotation survives the failed swap',
|
||||
);
|
||||
assert.equal(live.size, 0, 'and no half-swapped mark survives on the board');
|
||||
assert.ok(
|
||||
!getRenderGovernorDiagnostics().holds.includes('annotations'),
|
||||
@@ -1169,23 +1313,38 @@ test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing
|
||||
);
|
||||
|
||||
const retry = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol', color: 'amber' },
|
||||
{
|
||||
type: 'point',
|
||||
target: 'Texas Capitol',
|
||||
label: 'Capitol',
|
||||
color: 'amber',
|
||||
},
|
||||
]);
|
||||
assert.equal(retry.drawn, 1);
|
||||
assert.equal(live.size, 1, 'the recoloured mark redraws once, with no orphan underneath');
|
||||
assert.equal(
|
||||
live.size,
|
||||
1,
|
||||
'the recoloured mark redraws once, with no orphan underneath',
|
||||
);
|
||||
});
|
||||
|
||||
test('destroy releases the renderer and prevents subsequent annotation work', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
const { renderer, calls } = fakeRenderer();
|
||||
let destroyed = 0;
|
||||
renderer.destroy = () => { destroyed++; };
|
||||
renderer.destroy = () => {
|
||||
destroyed++;
|
||||
};
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
const drawn = await engine.annotate([{ type: 'pin', longitude: -97.74, latitude: 30.27 }]);
|
||||
const drawn = await engine.annotate([
|
||||
{ type: 'pin', longitude: -97.74, latitude: 30.27 },
|
||||
]);
|
||||
assert.equal(drawn.drawn, 1);
|
||||
engine.destroy();
|
||||
const additions = calls.add;
|
||||
const result = await engine.annotate([{ type: 'pin', target: 'Must not resolve' }]);
|
||||
const result = await engine.annotate([
|
||||
{ type: 'pin', target: 'Must not resolve' },
|
||||
]);
|
||||
assert.equal(result.error, 'destroyed');
|
||||
assert.equal(calls.add, additions);
|
||||
assert.equal(engine.count(), 0);
|
||||
@@ -1201,10 +1360,13 @@ test('a late annotation resolver cannot redraw after destruction', async (t) =>
|
||||
let release;
|
||||
let signal;
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {}, renderer,
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget: (options) => {
|
||||
signal = options.signal;
|
||||
return new Promise((resolve) => { release = resolve; });
|
||||
return new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
},
|
||||
});
|
||||
const pending = engine.annotate([{ type: 'pin', target: 'Pending place' }]);
|
||||
@@ -1216,6 +1378,44 @@ test('a late annotation resolver cannot redraw after destruction', async (t) =>
|
||||
assert.equal(engine.count(), 0);
|
||||
});
|
||||
|
||||
test('caller cancellation prevents a late annotation and camera mutation', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
const { renderer, calls } = fakeRenderer();
|
||||
let release;
|
||||
let resolverSignal;
|
||||
let flights = 0;
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {
|
||||
camera: {
|
||||
flyToBoundingSphere() {
|
||||
flights += 1;
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer,
|
||||
resolveTarget: ({ signal }) => {
|
||||
resolverSignal = signal;
|
||||
return new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = engine.annotate([{ type: 'pin', target: 'Pending place' }], {
|
||||
flyTo: true,
|
||||
signal: controller.signal,
|
||||
isCurrent: () => !controller.signal.aborted,
|
||||
});
|
||||
controller.abort();
|
||||
assert.equal(resolverSignal.aborted, true);
|
||||
release({ lon: -97.74, lat: 30.27, label: 'Pending place' });
|
||||
const result = await pending;
|
||||
assert.equal(result.aborted, true);
|
||||
assert.equal(calls.add, 0);
|
||||
assert.equal(flights, 0);
|
||||
assert.equal(engine.count(), 0);
|
||||
});
|
||||
|
||||
test('only an explicit navigation request permits resolving distant annotation targets', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
@@ -1223,12 +1423,22 @@ test('only an explicit navigation request permits resolving distant annotation t
|
||||
const { renderer } = throwingRendererHarness();
|
||||
renderer.destroy = () => {};
|
||||
const received = [];
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer,
|
||||
resolveTarget: async (options) => { received.push(options); return null; },
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget: async (options) => {
|
||||
received.push(options);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
await engine.annotate([{ type: 'point', target: 'Remote landmark' }]);
|
||||
await engine.annotate([{ type: 'point', target: 'Remote landmark' }], { flyTo: true });
|
||||
assert.deepEqual(received.map(options => options.allowDistant), [false, true]);
|
||||
await engine.annotate([{ type: 'point', target: 'Remote landmark' }], {
|
||||
flyTo: true,
|
||||
});
|
||||
assert.deepEqual(
|
||||
received.map((options) => options.allowDistant),
|
||||
[false, true],
|
||||
);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
|
||||
@@ -266,6 +266,8 @@ export function createOutlineRungs({
|
||||
heightM: null,
|
||||
synthesized: false,
|
||||
outlineName: outline.name,
|
||||
adminArea: outline.adminArea || null,
|
||||
adminLevel: outline.adminLevel || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -288,6 +290,9 @@ export function createOutlineRungs({
|
||||
const patch = finish(fp, asScope);
|
||||
if (patch?.ring) {
|
||||
patch.outlineCredit = provider === 'openfreemap' ? 'tiles' : 'osm';
|
||||
// Which source answered, so a stored area names it (OpenFreeMap or
|
||||
// Nominatim) instead of defaulting to the Overpass rungs.
|
||||
patch.outlineSource = provider;
|
||||
ctx.credit?.(patch.outlineCredit);
|
||||
// One line per answered outline, like the resolver's own trace.
|
||||
console.log(
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
createGateStateStore,
|
||||
createNominatimGate,
|
||||
} from '../../server/providers/regional/nominatimGate.js';
|
||||
import { createVoiceAreas } from '../voice/areaActions.js';
|
||||
|
||||
const TILES = [6745, 6746].map((y) =>
|
||||
decodeOpenFreeMapOutlineTile(
|
||||
@@ -145,7 +146,7 @@ test('a street comes from tiles before the configured ladder, as merged ribbons'
|
||||
);
|
||||
assert.ok(result.ring.length > 6);
|
||||
assert.equal(result.polygons.length, 1);
|
||||
assert.equal('outlineSource' in result, false);
|
||||
assert.equal(result.outlineSource, 'openfreemap');
|
||||
assert.equal(run.calls.base, 0, 'the ladder is not consulted');
|
||||
assert.deepEqual(run.calls.finished, ['street']);
|
||||
assert.deepEqual(credits, ['tiles']);
|
||||
@@ -415,6 +416,7 @@ test('a city with no configured Overpass is outlined by Nominatim', async () =>
|
||||
);
|
||||
assert.deepEqual(result.ring, ring);
|
||||
assert.equal(result.outlineCredit, 'osm');
|
||||
assert.equal(result.outlineSource, 'nominatim');
|
||||
assert.deepEqual(nominatim.asks[0], {
|
||||
query: 'Toulouse',
|
||||
kind: 'city',
|
||||
@@ -876,6 +878,19 @@ const NOMINATIM_ROWS = {
|
||||
geojson: { type: 'Polygon', coordinates: [square(40.78, -73.965, 0.01)] },
|
||||
},
|
||||
],
|
||||
'Bagmati Province': [
|
||||
{
|
||||
category: 'boundary',
|
||||
type: 'administrative',
|
||||
addresstype: 'province',
|
||||
name: 'Bagmati Province',
|
||||
lat: '27.7',
|
||||
lon: '85.3',
|
||||
osm_type: 'relation',
|
||||
osm_id: 12345,
|
||||
geojson: { type: 'Polygon', coordinates: [square(27.7, 85.3, 0.4)] },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const PLACES = {
|
||||
@@ -900,6 +915,13 @@ const PLACES = {
|
||||
label: 'Congress Ave, Austin',
|
||||
types: ['route'],
|
||||
},
|
||||
'Bagmati Province': {
|
||||
lat: 27.7,
|
||||
lng: 85.3,
|
||||
name: 'Bagmati Province',
|
||||
label: 'Bagmati Province, Nepal',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
},
|
||||
};
|
||||
|
||||
const viewerAt = (lat, lon, height = 4000) => ({
|
||||
@@ -977,6 +999,19 @@ test('default settings: outline, street and building asks send nothing to Overpa
|
||||
target: 'Central Park',
|
||||
});
|
||||
|
||||
const areas = createVoiceAreas({
|
||||
viewer: viewerAt(27.7, 85.3, 40_000),
|
||||
annotationResolver: resolver,
|
||||
placeSearch,
|
||||
});
|
||||
const verified = await areas.resolveAreaAction({
|
||||
query: 'Bagmati Province',
|
||||
level: 'admin1',
|
||||
});
|
||||
assert.equal(verified.ok, true);
|
||||
assert.equal(verified.sourceIdentity, 'Bagmati Province');
|
||||
assert.equal(areas.store.get(verified.areaId).meta.adminLevel, 'admin1');
|
||||
|
||||
const street = await resolve(viewerAt(30.27, -97.742), {
|
||||
target: 'Congress Avenue',
|
||||
});
|
||||
@@ -1000,7 +1035,7 @@ test('default settings: outline, street and building asks send nothing to Overpa
|
||||
);
|
||||
assert.equal(
|
||||
upstream.length,
|
||||
2,
|
||||
3,
|
||||
'one upstream request per explicit ask that needed one',
|
||||
);
|
||||
assert.ok(!upstream.some((url) => /overpass/i.test(url)));
|
||||
|
||||
@@ -103,6 +103,48 @@ test('"around the Capitol grounds" is still a grounds ask', async () => {
|
||||
assert.equal(asks.length, 1);
|
||||
});
|
||||
|
||||
test('Nominatim administrative identity survives the outline rung', async () => {
|
||||
const boundary = {
|
||||
polygons: [
|
||||
[
|
||||
[
|
||||
[84.4, 26.9],
|
||||
[86.6, 26.9],
|
||||
[86.6, 28.4],
|
||||
[84.4, 28.4],
|
||||
[84.4, 26.9],
|
||||
],
|
||||
],
|
||||
],
|
||||
class: 'boundary',
|
||||
type: 'administrative',
|
||||
adminArea: 'Bagmati Province',
|
||||
adminLevel: 'admin1',
|
||||
};
|
||||
const { rungs } = setup(boundary);
|
||||
const result = await rungs.resolve(
|
||||
ctx({
|
||||
scope: 'state',
|
||||
target: 'Bagmati Province',
|
||||
matchName: 'Bagmati Province',
|
||||
lat: 27.7,
|
||||
lon: 85.3,
|
||||
view: { lat: 27.7, lon: 85.3 },
|
||||
}),
|
||||
{
|
||||
base: async () => UNAVAILABLE,
|
||||
finish: (fp) => ({
|
||||
ring: fp.ring,
|
||||
footprintKind: fp.kind,
|
||||
adminArea: fp.adminArea,
|
||||
adminLevel: fp.adminLevel,
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(result.adminArea, 'Bagmati Province');
|
||||
assert.equal(result.adminLevel, 'admin1');
|
||||
});
|
||||
|
||||
test('named grounds around a building seed still require the original anchor', async () => {
|
||||
// A place-search anchor ~250 m from the dome, outside the Capitol lawn.
|
||||
const offLawn = { lat: 30.27265, lon: -97.73925 };
|
||||
|
||||
@@ -525,6 +525,46 @@ test('a coarse bundled admin-1 city waits for a finer outline', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('deferred outlines preserve provider administrative identity metadata', async () => {
|
||||
const overParis = viewerAt(48.8566, 2.3522, 40_000);
|
||||
const ring = square(48.8566, 2.3522, 0.05);
|
||||
const { resolve } = harness(PARIS_PLACE, {
|
||||
outlineRungs: rungsAnswering({
|
||||
ring,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
adminArea: 'Île-de-France',
|
||||
adminLevel: 'admin1',
|
||||
}),
|
||||
});
|
||||
const outline = await (await resolve('Paris', overParis)).resolveOutline();
|
||||
assert.equal(outline.adminArea, 'Île-de-France');
|
||||
assert.equal(outline.adminLevel, 'admin1');
|
||||
});
|
||||
|
||||
test('an explicitly distant area keeps its deferred outline', async () => {
|
||||
const kathmandu = {
|
||||
lat: 27.7172,
|
||||
lng: 85.324,
|
||||
label: 'Kathmandu, Nepal',
|
||||
name: 'Kathmandu',
|
||||
types: ['locality', 'political'],
|
||||
};
|
||||
const ring = square(27.7172, 85.324, 0.05);
|
||||
const { resolve } = harness(kathmandu, {
|
||||
outlineRungs: rungsAnswering({ ring, kind: 'area', heightM: null }),
|
||||
});
|
||||
const resolved = await resolve('Kathmandu', SAN_FRANCISCO, {
|
||||
allowDistant: true,
|
||||
});
|
||||
assert.equal(resolved.source, 'geocode');
|
||||
assert.deepEqual(
|
||||
(await resolved.resolveOutline()).ring,
|
||||
ring,
|
||||
'the deferred centroid gate honors the same explicit distance policy as the anchor',
|
||||
);
|
||||
});
|
||||
|
||||
test('detailed admin-1 units and admin asks keep the bundled shape', async () => {
|
||||
const { resolve } = harness(null, { outlineRungs: rungsAnswering(null) });
|
||||
for (const [name, lat, lon] of [
|
||||
|
||||
@@ -588,6 +588,7 @@ export function createAnnotationResolver({
|
||||
buildingHeight: null,
|
||||
synthesized: false,
|
||||
naturalRegion: ne.name,
|
||||
outlineSource: 'natural-earth',
|
||||
lat: neCentroid?.lat ?? lat,
|
||||
lon: neCentroid?.lon ?? lon,
|
||||
height: sampleGroundHeight(
|
||||
@@ -857,6 +858,7 @@ export function createAnnotationResolver({
|
||||
if (
|
||||
fromGeocode &&
|
||||
!bypassNearViewGuards &&
|
||||
!allowDistant &&
|
||||
gateDrift(centroid.lat, centroid.lon)
|
||||
)
|
||||
return null;
|
||||
@@ -867,6 +869,8 @@ export function createAnnotationResolver({
|
||||
synthesized: Boolean(fp.synthesized),
|
||||
outlineCredit: fp.outlineCredit || null,
|
||||
...(Array.isArray(fp.polygons) ? { polygons: fp.polygons } : {}),
|
||||
...(fp.adminArea ? { adminArea: fp.adminArea } : {}),
|
||||
...(fp.adminLevel ? { adminLevel: fp.adminLevel } : {}),
|
||||
lat: centroid.lat,
|
||||
lon: centroid.lon,
|
||||
height: sampleGroundHeight(viewer, centroid.lon, centroid.lat),
|
||||
@@ -1119,6 +1123,7 @@ export function createAnnotationResolver({
|
||||
buildingHeight: null,
|
||||
synthesized: false,
|
||||
adminArea: admin.name,
|
||||
outlineSource: admin.source,
|
||||
lat,
|
||||
lon,
|
||||
height: sampleGroundHeight(viewer, lon, lat),
|
||||
@@ -1169,6 +1174,7 @@ export function createAnnotationResolver({
|
||||
buildingHeight: null,
|
||||
label: admin.name,
|
||||
source: 'bundled',
|
||||
outlineSource: admin.source,
|
||||
synthesized: false,
|
||||
outlineUnavailable: false,
|
||||
viewport: {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createApplicationTraffic } from './layers/traffic.js';
|
||||
import { createApplicationBikeshare } from './layers/bikeshare.js';
|
||||
import { createApplicationDirections } from './layers/directions.js';
|
||||
import { createApplicationRecentImagery } from './layers/recentImagery.js';
|
||||
import { createApplicationOsmPlaces } from './layers/osmPlaces.js';
|
||||
import { createApplicationTransit } from './layers/transit.js';
|
||||
import { createApplicationInstallations } from './layers/militaryInstallations.js';
|
||||
import { createApplicationSatellites } from './layers/satellites.js';
|
||||
@@ -30,6 +31,7 @@ import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
||||
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
|
||||
import { createApplicationDroneAirspace } from './layers/droneAirspace.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';
|
||||
@@ -42,6 +44,8 @@ import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
|
||||
*/
|
||||
export const LOCAL_ONLY_LAYER_METADATA = Object.freeze([
|
||||
Object.freeze({ id: 'local-adsb', disposition: 'local-only' }),
|
||||
// Voice search results belong to this session, like a receiver to this machine.
|
||||
Object.freeze({ id: 'osm-places', disposition: 'local-only' }),
|
||||
]);
|
||||
|
||||
/** Serialization metadata for every layer the application catalog constructs. */
|
||||
@@ -148,6 +152,7 @@ export function createApplicationCatalog({
|
||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
||||
createApplicationDirections({ source: sources.directions }),
|
||||
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
|
||||
createApplicationOsmPlaces(),
|
||||
vessels,
|
||||
installations,
|
||||
createApplicationAwareness({
|
||||
@@ -173,6 +178,7 @@ export function createApplicationCatalog({
|
||||
clock: weatherClock,
|
||||
}),
|
||||
createCyclonesLayer({ feed: sources.cyclones }),
|
||||
...createOsmOverlayLayers(),
|
||||
...createInfrastructureLayers(localGeoJsonServices),
|
||||
createApplicationCables({ source: sources.cables }),
|
||||
createApplicationFirms({
|
||||
|
||||
@@ -40,8 +40,13 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
signal: b.signal,
|
||||
surface: fixtureSurface(b.signal),
|
||||
});
|
||||
assert.equal(first.layers.length, 34);
|
||||
assert.equal(first.layers.length, 37);
|
||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'osm-places'),
|
||||
{ id: 'osm-places', disposition: 'local-only' },
|
||||
'voice search results never enter share links or stored state',
|
||||
);
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'local-adsb'),
|
||||
{ id: 'local-adsb', disposition: 'local-only' },
|
||||
@@ -156,7 +161,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, 34);
|
||||
assert.equal(catalog.layers.length, 37);
|
||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||
assert.equal(
|
||||
catalogControlServices(catalog).streetLevelLayer,
|
||||
|
||||
Vendored
+7
@@ -1,6 +1,7 @@
|
||||
import { catalogControlServices } from './catalog.js';
|
||||
import { StyleManager } from '../ui/composition.js';
|
||||
import { flyToAustin } from '../camera.js';
|
||||
import { installZoomOutGuard } from './zoomOutGuard.js';
|
||||
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
@@ -41,6 +42,12 @@ export function createApplicationControls({
|
||||
defer(() => styleManager.orbitController.stop());
|
||||
defer(() => styleManager.hud.destroy());
|
||||
defer(() => styleManager.dispose());
|
||||
defer(
|
||||
installZoomOutGuard(viewer, {
|
||||
subscribeCameraHandoff: (listener) =>
|
||||
styleManager.subscribeCameraHandoff?.(listener),
|
||||
}),
|
||||
);
|
||||
// The previous multi-canvas weather compositor remains disabled. Cockpit
|
||||
// clouds use a separate, capped low-resolution GPU pass that never attaches
|
||||
// Cesium fog or post-process stages and is fully stopped in map mode.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import '../testSupport/stubMgrs.mjs';
|
||||
import { installVueDom } from '../testSupport/vueDom.mjs';
|
||||
import { MAXIMUM_ZOOM_DISTANCE_M } from './zoomOutGuard.js';
|
||||
|
||||
const { createApplicationControls } = await import('./controls.js');
|
||||
|
||||
let uninstallDom;
|
||||
before(() => {
|
||||
uninstallDom = installVueDom();
|
||||
});
|
||||
after(() => uninstallDom());
|
||||
|
||||
test('application controls install the zoom-out guard and remove it on teardown', () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const controller = { maximumZoomDistance: Infinity };
|
||||
const scene = {
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
screenSpaceCameraController: controller,
|
||||
preRender: new Cesium.Event(),
|
||||
postUpdate: new Cesium.Event(),
|
||||
};
|
||||
const camera = {
|
||||
position: Cesium.Cartesian3.fromDegrees(0, 0, 1_000_000),
|
||||
positionCartographic: { height: 1_000_000 },
|
||||
};
|
||||
const viewer = { scene, camera, canvas };
|
||||
const handoffs = new Set();
|
||||
class Controls {
|
||||
hasShareState = true;
|
||||
orbitController = { stop() {} };
|
||||
hud = { destroy() {} };
|
||||
dispose() {}
|
||||
subscribeCameraHandoff(listener) {
|
||||
handoffs.add(listener);
|
||||
return () => handoffs.delete(listener);
|
||||
}
|
||||
}
|
||||
const cleanups = [];
|
||||
createApplicationControls({
|
||||
scene: {
|
||||
viewer,
|
||||
mapStackController: {},
|
||||
operations: {
|
||||
surface: { controlServices: {} },
|
||||
searchAndFlyTo() {},
|
||||
requests: { regional: {}, weather: {} },
|
||||
},
|
||||
},
|
||||
loaderStatus: {},
|
||||
Controls,
|
||||
catalog: { get: (id) => ({ id }) },
|
||||
defer: (cleanup) => cleanups.push(cleanup),
|
||||
});
|
||||
|
||||
assert.equal(controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
|
||||
assert.equal(scene.preRender.numberOfListeners, 1);
|
||||
assert.equal(scene.postUpdate.numberOfListeners, 1);
|
||||
assert.equal(handoffs.size, 1);
|
||||
|
||||
for (const cleanup of cleanups.reverse()) cleanup();
|
||||
assert.equal(controller.maximumZoomDistance, Infinity);
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
assert.equal(scene.postUpdate.numberOfListeners, 0);
|
||||
assert.equal(handoffs.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createOsmPlacesLayer } from '../../layers/osmPlaces/index.js';
|
||||
import { overlayHost } from './overlayHost.js';
|
||||
|
||||
/** Wire OSM Places labels to the application overlay host. */
|
||||
export function createApplicationOsmPlaces() {
|
||||
return createOsmPlacesLayer({ overlayHost });
|
||||
}
|
||||
@@ -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, 34);
|
||||
assert.equal(actual.layers.length, 37);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
|
||||
/** Whole-Earth input limit; explicit camera destinations can go farther. */
|
||||
export const MAXIMUM_ZOOM_DISTANCE_M = 40_000_000;
|
||||
/** Below this, oblique browsing is left alone. */
|
||||
export const PITCH_CLAMP_MIN_HEIGHT_M = 300_000;
|
||||
/** Look this far below the Earth's limb, so the globe stays on screen. */
|
||||
export const LIMB_MARGIN_RAD = Cesium.Math.toRadians(8);
|
||||
const PITCH_EASE = 0.35;
|
||||
const PITCH_SNAP_RAD = Cesium.Math.toRadians(0.1);
|
||||
const HEIGHT_EPSILON_M = 0.0001;
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
const INPUT_BINDINGS = Object.values(Cesium.CameraEventType).flatMap((type) =>
|
||||
[undefined, ...Object.values(Cesium.KeyboardEventModifier)].map(
|
||||
(modifier) => ({
|
||||
type,
|
||||
modifier,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* The shallowest pitch (radians, negative is down) that still keeps the Earth
|
||||
* framed from height `h`, or null where tilted views are left alone.
|
||||
* The limb sits acos(R / (R + h)) below the local horizontal.
|
||||
*/
|
||||
export function maxPitchForHeight(h) {
|
||||
if (!(h >= PITCH_CLAMP_MIN_HEIGHT_M)) return null;
|
||||
const limb = Math.acos(EARTH_RADIUS_M / (EARTH_RADIUS_M + h));
|
||||
return -Math.min(limb + LIMB_MARGIN_RAD, Cesium.Math.PI_OVER_TWO);
|
||||
}
|
||||
|
||||
function matchesZoomBinding(bindings, type, modifier) {
|
||||
if (Array.isArray(bindings))
|
||||
return bindings.some((binding) =>
|
||||
matchesZoomBinding(binding, type, modifier),
|
||||
);
|
||||
return typeof bindings === 'number'
|
||||
? bindings === type && modifier === undefined
|
||||
: bindings?.eventType === type && bindings?.modifier === modifier;
|
||||
}
|
||||
|
||||
function readInput(aggregator, bindings) {
|
||||
let outward = false;
|
||||
let cancel = false;
|
||||
for (const { type, modifier } of INPUT_BINDINGS) {
|
||||
if (!aggregator.isMoving(type, modifier)) continue;
|
||||
const movement = aggregator.getMovement(type, modifier);
|
||||
const pinch = type === Cesium.CameraEventType.PINCH;
|
||||
const distance = pinch ? movement.distance : movement;
|
||||
const delta = distance.endPosition.y - distance.startPosition.y;
|
||||
// Cesium tilts by the fingers' midpoint while zooming by their spacing,
|
||||
// both in the same quarter-pixel units. Fingers rarely keep a perfectly
|
||||
// even spacing, so the larger of the two movements decides the gesture.
|
||||
const tilt = pinch
|
||||
? movement.angleAndHeight.endPosition.y -
|
||||
movement.angleAndHeight.startPosition.y
|
||||
: 0;
|
||||
if (
|
||||
matchesZoomBinding(bindings, type, modifier) &&
|
||||
Math.abs(delta) > Math.abs(tilt)
|
||||
) {
|
||||
outward ||= delta < 0;
|
||||
cancel ||= delta > 0;
|
||||
} else {
|
||||
// Includes modifier-specific tilts and parallel two-finger movement.
|
||||
cancel = true;
|
||||
}
|
||||
}
|
||||
aggregator.reset();
|
||||
return { outward, cancel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep outward user zooms bounded and framed, without taking camera ownership
|
||||
* from tilts, tracked entities or flights. Returns an idempotent disposer.
|
||||
*/
|
||||
export function installZoomOutGuard(
|
||||
viewer,
|
||||
{
|
||||
requestRender = governorRequestRender,
|
||||
subscribeCameraHandoff,
|
||||
createAggregator = (canvas) => new Cesium.CameraEventAggregator(canvas),
|
||||
} = {},
|
||||
) {
|
||||
const scene = viewer?.scene;
|
||||
const controller = scene?.screenSpaceCameraController;
|
||||
const camera = viewer?.camera;
|
||||
const canvas = viewer?.canvas;
|
||||
if (
|
||||
!controller ||
|
||||
!camera ||
|
||||
!canvas ||
|
||||
!scene.preRender ||
|
||||
!scene.postUpdate
|
||||
)
|
||||
throw new TypeError('A complete Cesium viewer is required');
|
||||
|
||||
const aggregator = createAggregator(canvas);
|
||||
const originalMaximumZoomDistance = controller.maximumZoomDistance;
|
||||
const ownsCeiling = originalMaximumZoomDistance > MAXIMUM_ZOOM_DISTANCE_M;
|
||||
let maximumZoomDistance = Math.min(
|
||||
originalMaximumZoomDistance,
|
||||
MAXIMUM_ZOOM_DISTANCE_M,
|
||||
);
|
||||
controller.maximumZoomDistance = maximumZoomDistance;
|
||||
// Cesium clamps any zoom that starts above the limit to the limit, in either
|
||||
// direction. A camera placed higher by a flight or share link keeps its own
|
||||
// height as the limit, so it zooms in gradually and can't climb further.
|
||||
const followCeiling = () => {
|
||||
if (!ownsCeiling || controller.maximumZoomDistance !== maximumZoomDistance)
|
||||
return;
|
||||
// While tracking, Cesium measures zoom from the target, not the Earth, so
|
||||
// the camera's altitude would lift the ceiling with every outward step.
|
||||
const untracked =
|
||||
scene.mode === Cesium.SceneMode.SCENE3D &&
|
||||
Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY);
|
||||
const height = untracked ? camera.positionCartographic?.height : undefined;
|
||||
maximumZoomDistance =
|
||||
height > MAXIMUM_ZOOM_DISTANCE_M ? height : MAXIMUM_ZOOM_DISTANCE_M;
|
||||
controller.maximumZoomDistance = maximumZoomDistance;
|
||||
};
|
||||
followCeiling();
|
||||
|
||||
let lastHeight = camera.positionCartographic?.height;
|
||||
let lastPosition = Cesium.Cartesian3.clone(camera.position);
|
||||
let lastHeading = camera.heading;
|
||||
let lastPitch = camera.pitch;
|
||||
let lastRoll = camera.roll;
|
||||
let zoomingOut = false;
|
||||
let settling = false;
|
||||
const cancel = () => {
|
||||
zoomingOut = false;
|
||||
settling = false;
|
||||
};
|
||||
const guard = () => {
|
||||
const input = readInput(aggregator, controller.zoomEventTypes);
|
||||
const height = camera.positionCartographic?.height;
|
||||
const climbing = height > lastHeight + HEIGHT_EPSILON_M;
|
||||
const descending = height < lastHeight - HEIGHT_EPSILON_M;
|
||||
try {
|
||||
if (
|
||||
scene.mode !== Cesium.SceneMode.SCENE3D ||
|
||||
controller.enableInputs === false ||
|
||||
controller.enableZoom === false ||
|
||||
!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY) ||
|
||||
// Cesium has no public running-flight flag.
|
||||
camera._currentFlight ||
|
||||
input.cancel ||
|
||||
(descending && !settling)
|
||||
) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
if (input.outward) {
|
||||
zoomingOut = true;
|
||||
// A genuine outward gesture can be blocked by the controller's ceiling.
|
||||
settling = true;
|
||||
}
|
||||
if (climbing) {
|
||||
if (!zoomingOut) return;
|
||||
settling = true;
|
||||
} else {
|
||||
zoomingOut = false;
|
||||
// Outward inertia can lower altitude when Cesium clamps slant range at
|
||||
// the ceiling. Finish that correction; fresh inward input cancels above.
|
||||
// A stationary correction owns only the pose it last produced.
|
||||
if (
|
||||
!input.outward &&
|
||||
((!descending &&
|
||||
!Cesium.Cartesian3.equals(camera.position, lastPosition)) ||
|
||||
Math.abs(Cesium.Math.negativePiToPi(camera.heading - lastHeading)) >
|
||||
1e-10 ||
|
||||
Math.abs(camera.pitch - lastPitch) > 1e-10 ||
|
||||
Math.abs(Cesium.Math.negativePiToPi(camera.roll - lastRoll)) >
|
||||
1e-10)
|
||||
)
|
||||
settling = false;
|
||||
if (!settling) return;
|
||||
}
|
||||
const maxPitch = maxPitchForHeight(height);
|
||||
if (maxPitch === null || !(camera.pitch > maxPitch)) {
|
||||
settling = false;
|
||||
} else {
|
||||
const error = maxPitch - camera.pitch;
|
||||
const easedPitch =
|
||||
Math.abs(error) <= PITCH_SNAP_RAD
|
||||
? maxPitch
|
||||
: camera.pitch + error * PITCH_EASE;
|
||||
// A large wheel step must not leave the view above the limb while easing.
|
||||
const limbPitch = -Math.acos(
|
||||
EARTH_RADIUS_M / (EARTH_RADIUS_M + height),
|
||||
);
|
||||
const pitch = Math.min(easedPitch, limbPitch);
|
||||
camera.setView({
|
||||
orientation: { heading: camera.heading, pitch, roll: camera.roll },
|
||||
});
|
||||
settling = pitch > maxPitch;
|
||||
}
|
||||
// Observe the end of inertia too; no arbitrary timeout cuts off a long zoom.
|
||||
if (zoomingOut || settling) requestRender('zoom-out-guard');
|
||||
} finally {
|
||||
lastHeight = camera.positionCartographic?.height;
|
||||
lastPosition = Cesium.Cartesian3.clone(camera.position, lastPosition);
|
||||
lastHeading = camera.heading;
|
||||
lastPitch = camera.pitch;
|
||||
lastRoll = camera.roll;
|
||||
}
|
||||
};
|
||||
const removePreRender = scene.preRender.addEventListener(guard);
|
||||
// Fires every frame, after Cesium has applied this frame's input, while
|
||||
// preRender fires only on frames that draw. Input that didn't move the camera
|
||||
// (such as wheel at the ceiling) would otherwise wait for an unrelated frame.
|
||||
const removePostUpdate = scene.postUpdate.addEventListener(() => {
|
||||
followCeiling();
|
||||
if (
|
||||
INPUT_BINDINGS.some(({ type, modifier }) =>
|
||||
aggregator.isMoving(type, modifier),
|
||||
)
|
||||
)
|
||||
requestRender('zoom-out-guard');
|
||||
});
|
||||
const removeCameraHandoff = subscribeCameraHandoff?.(() => {
|
||||
cancel();
|
||||
// Discard input queued before an explicit navigation action took ownership.
|
||||
aggregator.reset();
|
||||
});
|
||||
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
removeCameraHandoff?.();
|
||||
removePreRender?.();
|
||||
removePostUpdate?.();
|
||||
aggregator.destroy();
|
||||
if (controller.maximumZoomDistance === maximumZoomDistance)
|
||||
controller.maximumZoomDistance = originalMaximumZoomDistance;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
installZoomOutGuard,
|
||||
maxPitchForHeight,
|
||||
MAXIMUM_ZOOM_DISTANCE_M,
|
||||
} from './zoomOutGuard.js';
|
||||
|
||||
const deg = Cesium.Math.toDegrees;
|
||||
const { WHEEL, PINCH, RIGHT_DRAG, LEFT_DRAG, MIDDLE_DRAG } =
|
||||
Cesium.CameraEventType;
|
||||
const { CTRL, SHIFT, ALT } = Cesium.KeyboardEventModifier;
|
||||
|
||||
test('maxPitchForHeight leaves low-altitude tilt alone', () => {
|
||||
assert.equal(maxPitchForHeight(0), null);
|
||||
assert.equal(maxPitchForHeight(299_999), null);
|
||||
assert.equal(maxPitchForHeight(NaN), null);
|
||||
assert.equal(maxPitchForHeight(undefined), null);
|
||||
});
|
||||
|
||||
test('maxPitchForHeight looks 8 degrees below the limb', () => {
|
||||
assert.ok(Math.abs(deg(maxPitchForHeight(1_000_000)) + 38.19) < 0.01);
|
||||
assert.equal(deg(maxPitchForHeight(1e12)), -90);
|
||||
});
|
||||
|
||||
test('maxPitchForHeight steepens monotonically with height', () => {
|
||||
let previous = 0;
|
||||
for (let h = 300_000; h <= 100_000_000; h *= 1.5) {
|
||||
const pitch = maxPitchForHeight(h);
|
||||
assert.ok(pitch <= previous, `pitch at ${h} m`);
|
||||
previous = pitch;
|
||||
}
|
||||
});
|
||||
|
||||
function guardFixture({ maximumZoomDistance = Infinity } = {}) {
|
||||
const preRender = new Set();
|
||||
const postUpdate = new Set();
|
||||
const handoffs = new Set();
|
||||
const container = new EventTarget();
|
||||
const canvas = new EventTarget();
|
||||
const movements = new Map();
|
||||
const key = (type, modifier) => `${type}:${modifier}`;
|
||||
const aggregator = {
|
||||
resets: 0,
|
||||
destroys: 0,
|
||||
isMoving: (type, modifier) => movements.has(key(type, modifier)),
|
||||
getMovement: (type, modifier) => movements.get(key(type, modifier)),
|
||||
reset() {
|
||||
movements.clear();
|
||||
this.resets++;
|
||||
},
|
||||
destroy() {
|
||||
this.destroys++;
|
||||
movements.clear();
|
||||
},
|
||||
};
|
||||
const camera = {
|
||||
height: 170_000,
|
||||
longitude: 0,
|
||||
latitude: 0,
|
||||
heading: 0.4,
|
||||
pitch: Cesium.Math.toRadians(-25),
|
||||
roll: 0,
|
||||
transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
|
||||
views: [],
|
||||
get positionCartographic() {
|
||||
return {
|
||||
height: this.height,
|
||||
longitude: this.longitude,
|
||||
latitude: this.latitude,
|
||||
};
|
||||
},
|
||||
get position() {
|
||||
return this.positionWC;
|
||||
},
|
||||
get positionWC() {
|
||||
return Cesium.Cartesian3.fromRadians(
|
||||
this.longitude,
|
||||
this.latitude,
|
||||
this.height,
|
||||
);
|
||||
},
|
||||
setView({ orientation }) {
|
||||
this.views.push(orientation);
|
||||
this.heading = orientation.heading;
|
||||
this.pitch = orientation.pitch;
|
||||
this.roll = orientation.roll;
|
||||
},
|
||||
};
|
||||
const controller = {
|
||||
maximumZoomDistance,
|
||||
enableInputs: true,
|
||||
enableZoom: true,
|
||||
zoomEventTypes: [
|
||||
RIGHT_DRAG,
|
||||
WHEEL,
|
||||
PINCH,
|
||||
{ eventType: WHEEL, modifier: CTRL },
|
||||
],
|
||||
};
|
||||
const scene = {
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
screenSpaceCameraController: controller,
|
||||
preRender: {
|
||||
addEventListener(handler) {
|
||||
preRender.add(handler);
|
||||
return () => preRender.delete(handler);
|
||||
},
|
||||
},
|
||||
postUpdate: {
|
||||
addEventListener(handler) {
|
||||
postUpdate.add(handler);
|
||||
return () => postUpdate.delete(handler);
|
||||
},
|
||||
},
|
||||
};
|
||||
const renders = [];
|
||||
const dispose = installZoomOutGuard(
|
||||
{ camera, container, canvas, scene },
|
||||
{
|
||||
subscribeCameraHandoff(listener) {
|
||||
handoffs.add(listener);
|
||||
return () => handoffs.delete(listener);
|
||||
},
|
||||
createAggregator(element) {
|
||||
assert.equal(element, canvas);
|
||||
return aggregator;
|
||||
},
|
||||
requestRender: (reason) => renders.push(reason),
|
||||
},
|
||||
);
|
||||
// A frame Cesium skips drawing in request-render mode: no preRender.
|
||||
const idleFrame = () => {
|
||||
for (const handler of postUpdate) handler();
|
||||
};
|
||||
const frame = (height = camera.height) => {
|
||||
camera.height = height;
|
||||
idleFrame();
|
||||
for (const handler of preRender) handler();
|
||||
};
|
||||
const movement = (type, deltaY, { modifier, tiltDeltaY = 0 } = {}) => {
|
||||
const distance = {
|
||||
startPosition: new Cesium.Cartesian2(0, 0),
|
||||
endPosition: new Cesium.Cartesian2(0, deltaY),
|
||||
};
|
||||
movements.set(
|
||||
key(type, modifier),
|
||||
type === PINCH
|
||||
? {
|
||||
distance,
|
||||
angleAndHeight: {
|
||||
startPosition: new Cesium.Cartesian2(0, 0),
|
||||
endPosition: new Cesium.Cartesian2(0, tiltDeltaY),
|
||||
},
|
||||
}
|
||||
: distance,
|
||||
);
|
||||
// Also exercise the former raw DOM path when running against the PR head.
|
||||
const event = new Event(
|
||||
type === WHEEL ? 'wheel' : type === PINCH ? 'touchmove' : 'pointermove',
|
||||
);
|
||||
Object.assign(event, {
|
||||
buttons: type === RIGHT_DRAG ? 2 : type === MIDDLE_DRAG ? 4 : 1,
|
||||
ctrlKey: modifier === CTRL,
|
||||
shiftKey: modifier === SHIFT,
|
||||
altKey: modifier === ALT,
|
||||
touches: type === PINCH ? [1, 2] : [],
|
||||
});
|
||||
container.dispatchEvent(event);
|
||||
};
|
||||
return {
|
||||
camera,
|
||||
controller,
|
||||
scene,
|
||||
aggregator,
|
||||
preRender,
|
||||
postUpdate,
|
||||
handoffs,
|
||||
handoff: () => {
|
||||
for (const listener of handoffs) listener();
|
||||
},
|
||||
renders,
|
||||
dispose,
|
||||
frame,
|
||||
idleFrame,
|
||||
movement,
|
||||
};
|
||||
}
|
||||
|
||||
test('a tilted wheel zoom-out pitches down and preserves heading and roll', () => {
|
||||
const f = guardFixture();
|
||||
f.frame();
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(1_000_000);
|
||||
const target = maxPitchForHeight(1_000_000);
|
||||
assert.ok(f.camera.pitch < Cesium.Math.toRadians(-25));
|
||||
assert.ok(f.camera.pitch > target, 'eases rather than snapping');
|
||||
assert.equal(f.camera.heading, 0.4);
|
||||
assert.equal(f.camera.roll, 0);
|
||||
for (let i = 1; i <= 40; i++) f.frame(1_000_000 + i);
|
||||
assert.ok(
|
||||
Math.abs(f.camera.pitch - maxPitchForHeight(f.camera.height)) < 1e-3,
|
||||
);
|
||||
assert.ok(f.renders.length > 0);
|
||||
});
|
||||
|
||||
test('a large outward step immediately keeps the Earth limb in view', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(WHEEL, -100);
|
||||
f.frame(20_000_000);
|
||||
const limbPitch = -Math.acos(6_371_000 / (6_371_000 + 20_000_000));
|
||||
assert.ok(f.camera.pitch <= limbPitch);
|
||||
assert.ok(f.camera.pitch > maxPitchForHeight(20_000_000));
|
||||
});
|
||||
|
||||
test('pinch, right-drag and configured Ctrl+wheel can zoom out', () => {
|
||||
for (const [type, modifier] of [[PINCH], [RIGHT_DRAG], [WHEEL, CTRL]]) {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(type, -10, { modifier });
|
||||
f.frame(700_000);
|
||||
assert.equal(f.camera.views.length, 1, `${type}:${modifier}`);
|
||||
f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('Ctrl+right-drag tilt is left at the pose chosen by Cesium', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(RIGHT_DRAG, 3, { modifier: CTRL });
|
||||
// Real Cesium's orbit tilt raises altitude with no zoom movement.
|
||||
f.camera.pitch = Cesium.Math.toRadians(-26.07722459498313);
|
||||
f.frame(529_392.3759876974);
|
||||
assert.equal(f.camera.views.length, 0);
|
||||
assert.equal(deg(f.camera.pitch), -26.07722459498313);
|
||||
});
|
||||
|
||||
test('parallel two-finger tilt is not mistaken for pinch zoom', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(PINCH, 0, { tiltDeltaY: 3 });
|
||||
f.frame(600_000);
|
||||
assert.equal(f.camera.views.length, 0);
|
||||
});
|
||||
|
||||
test('two-finger tilt with finger-spacing drift is left to Cesium', () => {
|
||||
for (const [spacing, tilt] of [
|
||||
[-0.01, 3],
|
||||
[-2, 3],
|
||||
[-3, -3],
|
||||
]) {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(PINCH, spacing, { tiltDeltaY: tilt });
|
||||
// Real Cesium's two-finger tilt climbs and picks its own pitch.
|
||||
f.camera.pitch = Cesium.Math.toRadians(-26.07746);
|
||||
f.frame(529_406);
|
||||
assert.equal(f.camera.views.length, 0, `${spacing}, ${tilt}`);
|
||||
f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('a pinch that closes the fingers more than it moves them still zooms out', () => {
|
||||
// One finger still, the other sliding towards it: the midpoint moves half as far.
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(PINCH, -10, { tiltDeltaY: -5 });
|
||||
f.frame(700_000);
|
||||
assert.equal(f.camera.views.length, 1);
|
||||
f.dispose();
|
||||
});
|
||||
|
||||
test('unconfigured wheel modifiers and one-finger drag do not enable the guard', () => {
|
||||
for (const [type, modifier] of [[WHEEL, SHIFT], [WHEEL, ALT], [LEFT_DRAG]]) {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(type, -10, { modifier });
|
||||
f.frame(600_000);
|
||||
assert.equal(f.camera.views.length, 0, `${type}:${modifier}`);
|
||||
f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('current controller zoom bindings are honored', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.controller.zoomEventTypes = { eventType: MIDDLE_DRAG, modifier: ALT };
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(600_000);
|
||||
assert.equal(f.camera.views.length, 0);
|
||||
f.movement(MIDDLE_DRAG, -10, { modifier: ALT });
|
||||
f.frame(700_000);
|
||||
assert.equal(f.camera.views.length, 1);
|
||||
});
|
||||
|
||||
test('programmatic height changes alone never activate the guard', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(170_000);
|
||||
f.frame(2_000_000);
|
||||
f.frame(3_000_000);
|
||||
assert.equal(f.camera.views.length, 0);
|
||||
});
|
||||
|
||||
test('inward zoom does not activate the guard even when altitude rises', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(WHEEL, 10);
|
||||
f.frame(600_000);
|
||||
assert.equal(f.camera.views.length, 0);
|
||||
});
|
||||
|
||||
test('outward input with falling height does not alter pitch', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(2_000_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(1_500_000);
|
||||
f.frame(1_500_000);
|
||||
assert.equal(f.camera.views.length, 0);
|
||||
});
|
||||
|
||||
test('pitch finishes settling when the zoom stops changing height', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(2_000_000);
|
||||
const firstPitch = f.camera.pitch;
|
||||
for (let i = 0; i < 40; i++) f.frame();
|
||||
assert.ok(f.camera.pitch < firstPitch);
|
||||
assert.ok(Math.abs(f.camera.pitch - maxPitchForHeight(2_000_000)) < 1e-10);
|
||||
const views = f.camera.views.length;
|
||||
f.frame();
|
||||
assert.equal(
|
||||
f.camera.views.length,
|
||||
views,
|
||||
'settled pose does not keep rendering',
|
||||
);
|
||||
});
|
||||
|
||||
test('completed settling does not authorize a later programmatic climb', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(700_000);
|
||||
for (let i = 0; i < 40; i++) f.frame();
|
||||
const views = f.camera.views.length;
|
||||
f.frame(3_000_000);
|
||||
assert.equal(f.camera.views.length, views);
|
||||
});
|
||||
|
||||
test('a fresh inward zoom or unrelated gesture cancels pending correction', () => {
|
||||
for (const [type, delta, options] of [
|
||||
[WHEEL, 10, {}],
|
||||
[RIGHT_DRAG, 3, { modifier: CTRL }],
|
||||
[LEFT_DRAG, 3, {}],
|
||||
[MIDDLE_DRAG, 3, {}],
|
||||
[PINCH, 0, { tiltDeltaY: 3 }],
|
||||
]) {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(700_000);
|
||||
const views = f.camera.views.length;
|
||||
f.movement(type, delta, options);
|
||||
f.frame(800_000);
|
||||
f.frame(900_000);
|
||||
assert.equal(f.camera.views.length, views, `${type}:${options.modifier}`);
|
||||
f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('an external pose change at fixed height cancels pending correction', () => {
|
||||
for (const field of ['heading', 'pitch', 'roll', 'longitude', 'latitude']) {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(700_000);
|
||||
const views = f.camera.views.length;
|
||||
f.camera[field] += 0.1;
|
||||
f.frame();
|
||||
f.frame(900_000);
|
||||
assert.equal(f.camera.views.length, views, field);
|
||||
f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('disabled controls, flights, tracked transforms and non-3D scenes cancel intent', () => {
|
||||
const cases = [
|
||||
(f) => {
|
||||
f.controller.enableInputs = false;
|
||||
return () => {
|
||||
f.controller.enableInputs = true;
|
||||
};
|
||||
},
|
||||
(f) => {
|
||||
f.controller.enableZoom = false;
|
||||
return () => {
|
||||
f.controller.enableZoom = true;
|
||||
};
|
||||
},
|
||||
(f) => {
|
||||
f.camera._currentFlight = {};
|
||||
return () => {
|
||||
delete f.camera._currentFlight;
|
||||
};
|
||||
},
|
||||
(f) => {
|
||||
f.camera.transform = Cesium.Matrix4.fromTranslation(
|
||||
new Cesium.Cartesian3(1, 2, 3),
|
||||
);
|
||||
return () => {
|
||||
f.camera.transform = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY);
|
||||
};
|
||||
},
|
||||
(f) => {
|
||||
f.scene.mode = Cesium.SceneMode.SCENE2D;
|
||||
return () => {
|
||||
f.scene.mode = Cesium.SceneMode.SCENE3D;
|
||||
};
|
||||
},
|
||||
];
|
||||
for (const block of cases) {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(700_000);
|
||||
const views = f.camera.views.length;
|
||||
const restore = block(f);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(800_000);
|
||||
restore();
|
||||
f.frame(900_000);
|
||||
assert.equal(f.camera.views.length, views);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(1_000_000);
|
||||
assert.equal(f.camera.views.length, views + 1);
|
||||
f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('installer sets the ceiling and disposer releases aggregator and frame listener', () => {
|
||||
const f = guardFixture();
|
||||
assert.equal(f.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
|
||||
assert.equal(f.preRender.size, 1);
|
||||
assert.equal(f.postUpdate.size, 1);
|
||||
f.frame();
|
||||
assert.ok(f.aggregator.resets > 0);
|
||||
f.dispose();
|
||||
f.dispose();
|
||||
assert.equal(f.controller.maximumZoomDistance, Infinity);
|
||||
assert.equal(f.aggregator.destroys, 1);
|
||||
assert.equal(f.preRender.size, 0);
|
||||
assert.equal(f.postUpdate.size, 0);
|
||||
assert.equal(f.handoffs.size, 0);
|
||||
});
|
||||
|
||||
test('installer preserves a stricter host zoom ceiling', () => {
|
||||
const f = guardFixture({ maximumZoomDistance: 123 });
|
||||
assert.equal(f.controller.maximumZoomDistance, 123);
|
||||
f.dispose();
|
||||
assert.equal(f.controller.maximumZoomDistance, 123);
|
||||
});
|
||||
|
||||
test('disposer keeps a ceiling someone else changed later', () => {
|
||||
const f = guardFixture();
|
||||
f.controller.maximumZoomDistance = 5;
|
||||
f.dispose();
|
||||
assert.equal(f.controller.maximumZoomDistance, 5);
|
||||
});
|
||||
|
||||
test('installer rejects an incomplete viewer', () => {
|
||||
assert.throws(() => installZoomOutGuard({}), TypeError);
|
||||
});
|
||||
|
||||
test('outward input at the ceiling reframes a deliberately tilted camera', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(MAXIMUM_ZOOM_DISTANCE_M);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame();
|
||||
for (let i = 0; i < 40; i++) f.frame();
|
||||
assert.ok(
|
||||
Math.abs(f.camera.pitch - maxPitchForHeight(MAXIMUM_ZOOM_DISTANCE_M)) <
|
||||
1e-10,
|
||||
);
|
||||
f.dispose();
|
||||
});
|
||||
|
||||
test('input that leaves the camera still requests a frame to read it', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(MAXIMUM_ZOOM_DISTANCE_M);
|
||||
f.idleFrame();
|
||||
assert.deepEqual(f.renders, [], 'an idle scene stays idle');
|
||||
// At the ceiling Cesium doesn't move the camera, so nothing would draw.
|
||||
f.movement(WHEEL, -10);
|
||||
f.idleFrame();
|
||||
assert.deepEqual(f.renders, ['zoom-out-guard']);
|
||||
f.frame();
|
||||
assert.ok(f.camera.pitch < Cesium.Math.toRadians(-25), 'read that frame');
|
||||
f.dispose();
|
||||
});
|
||||
|
||||
test('equivalent heading and roll wraps do not cancel stationary settling', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(2_000_000);
|
||||
f.camera.heading += Cesium.Math.TWO_PI;
|
||||
f.camera.roll += Cesium.Math.TWO_PI;
|
||||
for (let i = 0; i < 40; i++) f.frame();
|
||||
assert.ok(Math.abs(f.camera.pitch - maxPitchForHeight(2_000_000)) < 1e-10);
|
||||
f.dispose();
|
||||
});
|
||||
|
||||
test('a real Cesium camera settles across the north heading wrap at Istanbul', () => {
|
||||
const scene = {
|
||||
canvas: { clientWidth: 1200, clientHeight: 800, width: 1200, height: 800 },
|
||||
drawingBufferWidth: 1200,
|
||||
drawingBufferHeight: 800,
|
||||
mapProjection: new Cesium.GeographicProjection(),
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
pixelRatio: 1,
|
||||
preRender: new Cesium.Event(),
|
||||
postUpdate: new Cesium.Event(),
|
||||
screenSpaceCameraController: {
|
||||
maximumZoomDistance: Infinity,
|
||||
enableInputs: true,
|
||||
zoomEventTypes: [WHEEL],
|
||||
},
|
||||
};
|
||||
const camera = new Cesium.Camera(scene);
|
||||
scene.camera = camera;
|
||||
const setHeight = (height) =>
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(28.9784, 41.0082, height),
|
||||
orientation: { heading: 0, pitch: Cesium.Math.toRadians(-25), roll: 0 },
|
||||
});
|
||||
setHeight(1_000_000);
|
||||
let outward = false;
|
||||
const dispose = installZoomOutGuard(
|
||||
{ scene, camera, canvas: scene.canvas },
|
||||
{
|
||||
createAggregator: () => ({
|
||||
isMoving: (type, modifier) =>
|
||||
outward && type === WHEEL && modifier === undefined,
|
||||
getMovement: () => ({
|
||||
startPosition: { y: 0 },
|
||||
endPosition: { y: -10 },
|
||||
}),
|
||||
reset: () => {
|
||||
outward = false;
|
||||
},
|
||||
destroy() {},
|
||||
}),
|
||||
requestRender() {},
|
||||
},
|
||||
);
|
||||
try {
|
||||
setHeight(MAXIMUM_ZOOM_DISTANCE_M);
|
||||
outward = true;
|
||||
for (let i = 0; i < 40; i++) scene.preRender.raiseEvent(scene);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
camera.pitch - maxPitchForHeight(camera.positionCartographic.height),
|
||||
) < 1e-8,
|
||||
);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('an outward inertia ceiling adjustment can descend while pitch settles', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(39_000_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(39_999_999);
|
||||
const before = f.camera.pitch;
|
||||
// Cesium limits slant range on an inertial pick, slightly below the altitude cap.
|
||||
f.frame(39_997_258);
|
||||
for (let i = 0; i < 40; i++) f.frame();
|
||||
assert.ok(f.camera.pitch < before);
|
||||
assert.ok(
|
||||
Math.abs(f.camera.pitch - maxPitchForHeight(f.camera.height)) < 1e-10,
|
||||
);
|
||||
f.dispose();
|
||||
});
|
||||
|
||||
test('a descending programmatic tilt cancels pending correction', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(39_000_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(39_999_999);
|
||||
const count = f.camera.views.length;
|
||||
f.camera.pitch += 0.1;
|
||||
f.frame(39_997_258);
|
||||
for (let i = 0; i < 40; i++) f.frame();
|
||||
assert.equal(f.camera.views.length, count);
|
||||
f.dispose();
|
||||
});
|
||||
|
||||
test('explicit navigation cancels outward inertia before a climbing UI tilt', () => {
|
||||
const f = guardFixture();
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(1_000_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.handoff();
|
||||
const writes = f.camera.views.length;
|
||||
f.camera.pitch = Cesium.Math.toRadians(-25);
|
||||
f.frame(1_100_000);
|
||||
f.frame(1_200_000);
|
||||
assert.equal(f.camera.views.length, writes);
|
||||
assert.equal(deg(f.camera.pitch), -25);
|
||||
// A subsequent user zoom starts a new correction normally.
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(1_300_000);
|
||||
assert.ok(f.camera.views.length > writes);
|
||||
f.dispose();
|
||||
});
|
||||
|
||||
// A real Cesium camera and controller, fed by DOM wheel events on the canvas.
|
||||
function realControllerScene(startHeight) {
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
clientWidth: 1200,
|
||||
clientHeight: 800,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
style: {},
|
||||
// Keep Cesium's input handler off the missing `document`.
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
getBoundingClientRect: () => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
}),
|
||||
});
|
||||
const scene = {
|
||||
canvas,
|
||||
drawingBufferWidth: 1200,
|
||||
drawingBufferHeight: 800,
|
||||
mapProjection: new Cesium.GeographicProjection(),
|
||||
ellipsoid: Cesium.Ellipsoid.WGS84,
|
||||
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
pixelRatio: 1,
|
||||
verticalExaggeration: 1,
|
||||
verticalExaggerationRelativeHeight: 0,
|
||||
preRender: new Cesium.Event(),
|
||||
postUpdate: new Cesium.Event(),
|
||||
};
|
||||
const camera = new Cesium.Camera(scene);
|
||||
scene.camera = camera;
|
||||
const controller = new Cesium.ScreenSpaceCameraController(scene);
|
||||
controller.inertiaZoom = 0;
|
||||
scene.screenSpaceCameraController = controller;
|
||||
const setHeight = (h) =>
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(28.9784, 41.0082, h),
|
||||
orientation: { heading: 0, pitch: -Cesium.Math.PI_OVER_TWO, roll: 0 },
|
||||
});
|
||||
setHeight(startHeight);
|
||||
const dispose = installZoomOutGuard(
|
||||
{ scene, camera, canvas },
|
||||
{ requestRender() {} },
|
||||
);
|
||||
// Scene.render order: the controller applies input, then postUpdate, then
|
||||
// preRender on frames that draw.
|
||||
const frame = () => {
|
||||
controller.update();
|
||||
scene.postUpdate.raiseEvent(scene);
|
||||
scene.preRender.raiseEvent(scene);
|
||||
};
|
||||
const wheel = (deltaY) =>
|
||||
canvas.dispatchEvent(
|
||||
Object.assign(new Event('wheel'), {
|
||||
deltaY,
|
||||
deltaMode: 0,
|
||||
clientX: 600,
|
||||
clientY: 400,
|
||||
}),
|
||||
);
|
||||
const height = () => camera.positionCartographic.height;
|
||||
return {
|
||||
scene,
|
||||
camera,
|
||||
controller,
|
||||
setHeight,
|
||||
frame,
|
||||
wheel,
|
||||
height,
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
test('a camera placed above the ceiling cannot zoom out and zooms in without a jump', () => {
|
||||
const s = realControllerScene(60_000_000);
|
||||
try {
|
||||
s.frame();
|
||||
s.wheel(1);
|
||||
s.frame();
|
||||
assert.ok(Math.abs(s.height() - 60_000_000) < 1, `${s.height()} m`);
|
||||
s.wheel(-1);
|
||||
s.frame();
|
||||
const firstStep = s.height();
|
||||
assert.ok(firstStep < 60_000_000, 'zooms in');
|
||||
assert.ok(firstStep > 50_000_000, `${firstStep} m is gradual`);
|
||||
// The camera can no longer climb back above where it has descended to.
|
||||
s.wheel(1);
|
||||
s.frame();
|
||||
assert.ok(Math.abs(s.height() - firstStep) < 1, `${s.height()} m`);
|
||||
for (let i = 0; i < 40; i++) {
|
||||
s.wheel(-100);
|
||||
s.frame();
|
||||
}
|
||||
assert.ok(s.height() < MAXIMUM_ZOOM_DISTANCE_M);
|
||||
assert.equal(s.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
|
||||
for (let i = 0; i < 80; i++) {
|
||||
s.wheel(100);
|
||||
s.frame();
|
||||
}
|
||||
assert.ok(Math.abs(s.height() - MAXIMUM_ZOOM_DISTANCE_M) < 1_000);
|
||||
} finally {
|
||||
s.dispose();
|
||||
}
|
||||
assert.equal(s.controller.maximumZoomDistance, Infinity);
|
||||
});
|
||||
|
||||
test('a share-link view restored above the ceiling is honoured by later frames', () => {
|
||||
const s = realControllerScene(1_000_000);
|
||||
try {
|
||||
s.frame();
|
||||
s.setHeight(97_679_259);
|
||||
s.frame();
|
||||
s.wheel(100);
|
||||
s.frame();
|
||||
assert.ok(Math.abs(s.height() - 97_679_259) < 1, `${s.height()} m`);
|
||||
} finally {
|
||||
s.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('a tracked camera cannot climb past the ceiling from its target', () => {
|
||||
const s = realControllerScene(1_000_000);
|
||||
try {
|
||||
s.frame();
|
||||
// In look-at mode Cesium measures zoom from the target, not the Earth.
|
||||
s.camera.lookAt(
|
||||
Cesium.Cartesian3.fromDegrees(0, 0, 35_786_000),
|
||||
new Cesium.HeadingPitchRange(0, -0.5, 5_000_000),
|
||||
);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
s.wheel(100);
|
||||
s.frame();
|
||||
}
|
||||
const range = Cesium.Cartesian3.magnitude(s.camera.position);
|
||||
assert.ok(range <= MAXIMUM_ZOOM_DISTANCE_M + 1_000, `${range} m`);
|
||||
assert.equal(s.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
|
||||
} finally {
|
||||
s.dispose();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @module awarenessEcho
|
||||
* @description The Global Context sonar echo field. The awareness layer
|
||||
* publishes it while its range pulses run; the detection overlay reads it to
|
||||
* pulse the brackets of contacts a pulse front has just crossed. It holds no
|
||||
* awareness policy of its own: the publisher passes radius, pulse timing and
|
||||
* echo styling in, so the overlay never imports awareness internals.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* @typedef {object} AwarenessEchoField
|
||||
* @property {Cesium.Cartesian3} center Subject's ground point. The publisher
|
||||
* may move it in place; it is read on every query.
|
||||
* @property {number} radiusM Range the pulses grow to.
|
||||
* @property {{count: number, periodMs: number, startFraction: number, fadeInFraction?: number}} pulse
|
||||
* @property {{fadeMs: number, layerIds: string[]}} echo
|
||||
* @property {(nowMs: number) => number} [level] How far the pulses are
|
||||
* switched on, 0 to 1; echoes scale with it, so they rest with the pulses.
|
||||
*/
|
||||
|
||||
/** @type {AwarenessEchoField|null} */
|
||||
let field = null;
|
||||
let layerIds = new Set();
|
||||
const groundScratch = new Cesium.Cartesian3();
|
||||
|
||||
/**
|
||||
* Start (or replace) the echo field.
|
||||
* @param {AwarenessEchoField} next
|
||||
* @returns {void}
|
||||
*/
|
||||
export function setAwarenessEchoField(next) {
|
||||
field = next;
|
||||
layerIds = new Set(next.echo.layerIds);
|
||||
}
|
||||
|
||||
/** Stop all echoes. Safe when none were set. */
|
||||
export function clearAwarenessEchoField() {
|
||||
field = null;
|
||||
layerIds = new Set();
|
||||
}
|
||||
|
||||
/** @returns {AwarenessEchoField|null} The field the overlay styles from. */
|
||||
export function getAwarenessEchoField() {
|
||||
return field;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any echo can light at `nowMs`: a field is set and its pulses are
|
||||
* running. The overlay checks this once per frame, so resting pulses cost no
|
||||
* per-contact work.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function awarenessEchoActive(nowMs) {
|
||||
return Boolean(field) && (field.level?.(nowMs) ?? 1) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How lit a contact's echo is at `nowMs`: 1 the moment a pulse front reaches
|
||||
* its distance, fading as (1 - elapsed / `fadeMs`) squared, 0 otherwise. The
|
||||
* strongest pulse wins. A front that has wrapped back to the centre still
|
||||
* counts for the contact it last crossed, so rim contacts fade out instead of
|
||||
* cutting off. Nothing nearer than the birth ring `startFraction` out echoes,
|
||||
* and within the pulse's `fadeInFraction` the echo is scaled down with it.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @param {number} distanceM Contact's distance from the subject.
|
||||
* @param {number} radiusM Range the pulses grow to.
|
||||
* @param {{count: number, periodMs: number, startFraction: number, fadeInFraction?: number}} pulse
|
||||
* @param {{fadeMs: number}} echo
|
||||
* @returns {number} 0 to 1.
|
||||
*/
|
||||
export function awarenessEchoStrength(nowMs, distanceM, radiusM, pulse, echo) {
|
||||
// Pulses are born `startFraction` out, so nothing nearer (the subject
|
||||
// itself, its wingmen) is ever crossed; without this it lit on every birth.
|
||||
if (
|
||||
!Number.isFinite(distanceM) ||
|
||||
distanceM < radiusM * pulse.startFraction ||
|
||||
distanceM > radiusM
|
||||
)
|
||||
return 0;
|
||||
// The phase at which a pulse front sits at this distance.
|
||||
const touchPhase =
|
||||
(distanceM / radiusM - pulse.startFraction) / (1 - pulse.startFraction);
|
||||
let strength = 0;
|
||||
for (let index = 0; index < pulse.count; index++) {
|
||||
const cycle = nowMs / pulse.periodMs + index / pulse.count;
|
||||
let since = cycle - Math.floor(cycle) - touchPhase;
|
||||
// Rounding at the exact crossing must not read as a whole period ago.
|
||||
if (since < -1e-9) since += 1;
|
||||
else if (since < 0) since = 0;
|
||||
const elapsedMs = since * pulse.periodMs;
|
||||
if (elapsedMs < echo.fadeMs)
|
||||
strength = Math.max(strength, (1 - elapsedMs / echo.fadeMs) ** 2);
|
||||
}
|
||||
// A front still fading in over `fadeInFraction` of its travel echoes no
|
||||
// brighter than it shows.
|
||||
const fadeIn = pulse.fadeInFraction ?? 0;
|
||||
return fadeIn > 0 ? strength * Math.min(1, touchPhase / fadeIn) : strength;
|
||||
}
|
||||
|
||||
/**
|
||||
* The echo for one contact of `layerId` at world `position`, or 0 when no
|
||||
* field is set or the layer does not echo. Distance is measured between
|
||||
* ground points, so an aircraft's altitude does not shift its echo.
|
||||
* @param {string} layerId Owning layer of the contact.
|
||||
* @param {Cesium.Cartesian3} position Contact's world position.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @returns {number} 0 to 1.
|
||||
*/
|
||||
export function awarenessEchoAt(layerId, position, nowMs) {
|
||||
if (!field || !position || !layerIds.has(layerId)) return 0;
|
||||
const level = field.level?.(nowMs) ?? 1;
|
||||
if (!(level > 0)) return 0;
|
||||
const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(
|
||||
position,
|
||||
groundScratch,
|
||||
);
|
||||
if (!ground) return 0;
|
||||
return (
|
||||
level *
|
||||
awarenessEchoStrength(
|
||||
nowMs,
|
||||
Cesium.Cartesian3.distance(field.center, ground),
|
||||
field.radiusM,
|
||||
field.pulse,
|
||||
field.echo,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The echo bracket the detection overlay draws around one contact, or null
|
||||
* when it has no echo. `band` is the alpha band, 1 to `steps`, the echo's
|
||||
* strength times the contact bracket's own `bracketAlpha` rounded to the
|
||||
* nearest band, so an echo is never drawn brighter than its bracket; one
|
||||
* that rounds to nothing is not drawn. `spreadPx` is how far the echo sits
|
||||
* outside the contact's bracket, 0 as the front arrives, growing to the
|
||||
* field's `spreadPx` as it fades.
|
||||
* @param {string} layerId Owning layer of the contact.
|
||||
* @param {Cesium.Cartesian3} position Contact's world position.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @param {number} bracketAlpha The contact bracket's drawn alpha, 0 to 1.
|
||||
* @param {number} steps Alpha bands the overlay batches by.
|
||||
* @returns {{band: number, spreadPx: number}|null}
|
||||
*/
|
||||
export function awarenessEchoBracket(
|
||||
layerId,
|
||||
position,
|
||||
nowMs,
|
||||
bracketAlpha,
|
||||
steps,
|
||||
) {
|
||||
const echo = awarenessEchoAt(layerId, position, nowMs);
|
||||
if (!(echo > 0.01)) return null;
|
||||
const band = Math.min(steps, Math.round(echo * bracketAlpha * steps));
|
||||
if (!(band >= 1)) return null;
|
||||
return { band, spreadPx: field.echo.spreadPx * (1 - echo) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The echo's stroke colour: the field's own colour on the default detection
|
||||
* style, and the style's line colour under the others (NVG, FLIR, CRT), so
|
||||
* an echo never stands out of a monochrome palette.
|
||||
* @param {string} styleLine The active detection style's line colour.
|
||||
* @param {boolean} defaultStyle Whether the default detection style is active.
|
||||
* @returns {string|null} Null when no field is set.
|
||||
*/
|
||||
export function awarenessEchoColor(styleLine, defaultStyle) {
|
||||
if (!field) return null;
|
||||
return defaultStyle ? field.echo.color : styleLine;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// src/awarenessEcho.test.mjs — the Global Context echo field: when a sonar
|
||||
// pulse front crosses a flight or vessel, its bracket echo lights and fades.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
awarenessEchoActive,
|
||||
awarenessEchoAt,
|
||||
awarenessEchoStrength,
|
||||
clearAwarenessEchoField,
|
||||
getAwarenessEchoField,
|
||||
setAwarenessEchoField,
|
||||
} from './awarenessEcho.js';
|
||||
|
||||
const R = 250_000;
|
||||
const pulse = { count: 3, periodMs: 9000, startFraction: 0.02 };
|
||||
const echo = { fadeMs: 1400, layerIds: ['flights', 'ais-live-vessels'] };
|
||||
const strength = (nowMs, distanceM) =>
|
||||
awarenessEchoStrength(nowMs, distanceM, R, pulse, echo);
|
||||
/** The moment pulse 0's front sits at `distanceM`, in a later cycle. */
|
||||
const arrival = (distanceM) =>
|
||||
((distanceM / R - pulse.startFraction) / (1 - pulse.startFraction)) *
|
||||
pulse.periodMs +
|
||||
pulse.periodMs * 4;
|
||||
|
||||
test('an echo lights as a pulse front reaches the contact, then fades', () => {
|
||||
const d = 100_000;
|
||||
const t0 = arrival(d);
|
||||
assert.ok(Math.abs(strength(t0, d) - 1) < 1e-6);
|
||||
assert.ok(Math.abs(strength(t0 + echo.fadeMs / 2, d) - 0.25) < 1e-6);
|
||||
assert.equal(strength(t0 + echo.fadeMs + 1, d), 0);
|
||||
assert.equal(strength(t0 - 50, d), 0, 'dark just before the front');
|
||||
// Every pulse lights it once a period: `count` separate flashes in any
|
||||
// one-period window that starts after a flash has faded.
|
||||
let flashes = 0;
|
||||
let wasLit = false;
|
||||
const from = t0 + echo.fadeMs + 1;
|
||||
for (let ms = 0; ms < pulse.periodMs; ms += 10) {
|
||||
const lit = strength(from + ms, d) > 0.5;
|
||||
if (lit && !wasLit) flashes++;
|
||||
wasLit = lit;
|
||||
}
|
||||
assert.equal(flashes, pulse.count);
|
||||
});
|
||||
|
||||
test('rim contacts fade out after the front wraps; outside the range never', () => {
|
||||
const rim = R * 0.999;
|
||||
assert.ok(strength(arrival(rim) + echo.fadeMs / 2, rim) > 0.2);
|
||||
// Inside the ring pulses are born on, nothing is ever crossed: the
|
||||
// subject and anything within 5 km never echo, at any moment.
|
||||
const inner = R * pulse.startFraction;
|
||||
for (let ms = 0; ms < pulse.periodMs; ms += 10) {
|
||||
assert.equal(strength(ms, 0), 0, 'the subject itself');
|
||||
assert.equal(strength(ms, inner - 1), 0);
|
||||
}
|
||||
assert.ok(strength(pulse.periodMs * 5, inner) > 0.99, 'the birth ring');
|
||||
for (let ms = 0; ms < pulse.periodMs; ms += 50)
|
||||
assert.equal(strength(ms, R + 1), 0);
|
||||
assert.equal(strength(0, Number.NaN), 0);
|
||||
assert.equal(strength(0, -1), 0);
|
||||
});
|
||||
|
||||
test('awarenessEchoAt reads the published field, by layer, at ground level', (t) => {
|
||||
t.after(clearAwarenessEchoField);
|
||||
const contact = Cesium.Cartesian3.fromDegrees(3.5, 27, 0);
|
||||
assert.equal(awarenessEchoAt('flights', contact, 0), 0, 'no field, no echo');
|
||||
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
|
||||
setAwarenessEchoField({ center, radiusM: R, pulse, echo });
|
||||
assert.ok(getAwarenessEchoField());
|
||||
const d = Cesium.Cartesian3.distance(center, contact);
|
||||
const t0 = arrival(d);
|
||||
assert.ok(awarenessEchoAt('flights', contact, t0) > 0.99);
|
||||
assert.ok(awarenessEchoAt('ais-live-vessels', contact, t0) > 0.99);
|
||||
// Layers outside the echo list never light.
|
||||
assert.equal(awarenessEchoAt('cctv', contact, t0), 0);
|
||||
assert.equal(awarenessEchoAt('military-installations', contact, t0), 0);
|
||||
// An aircraft 11 km up echoes with the ground point under it.
|
||||
const aloft = Cesium.Cartesian3.fromDegrees(3.5, 27, 11_000);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
awarenessEchoAt('flights', aloft, t0 + 300) -
|
||||
awarenessEchoAt('flights', contact, t0 + 300),
|
||||
) < 0.01,
|
||||
);
|
||||
// The field's centre is read in place, so a moved subject moves the echo:
|
||||
// about 50 km farther, the contact lights at its new distance's moment.
|
||||
Cesium.Cartesian3.clone(Cesium.Cartesian3.fromDegrees(3.5, 26.1, 0), center);
|
||||
const moved = Cesium.Cartesian3.distance(center, contact);
|
||||
assert.ok(Math.abs(moved - d) > 1000);
|
||||
assert.ok(awarenessEchoAt('flights', contact, arrival(moved)) > 0.99);
|
||||
assert.equal(awarenessEchoAt('flights', null, t0), 0);
|
||||
clearAwarenessEchoField();
|
||||
assert.equal(getAwarenessEchoField(), null);
|
||||
assert.equal(awarenessEchoAt('flights', contact, t0), 0);
|
||||
});
|
||||
|
||||
test('echoes scale with the field level and rest with the pulses', (t) => {
|
||||
t.after(clearAwarenessEchoField);
|
||||
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
|
||||
// ~100 km east of the subject, on the ground.
|
||||
const contact = Cesium.Cartesian3.fromDegrees(4.008, 27, 0);
|
||||
let level = 1;
|
||||
setAwarenessEchoField({
|
||||
center,
|
||||
radiusM: R,
|
||||
pulse,
|
||||
echo,
|
||||
level: () => level,
|
||||
});
|
||||
const d = Cesium.Cartesian3.distance(center, contact);
|
||||
const t0 = arrival(d);
|
||||
const full = awarenessEchoAt('flights', contact, t0);
|
||||
assert.ok(full > 0.99);
|
||||
level = 0.5;
|
||||
assert.ok(
|
||||
Math.abs(awarenessEchoAt('flights', contact, t0) - full / 2) < 1e-9,
|
||||
);
|
||||
assert.equal(awarenessEchoActive(t0), true);
|
||||
level = 0;
|
||||
assert.equal(awarenessEchoAt('flights', contact, t0), 0);
|
||||
// At rest the overlay skips echoes for the whole frame.
|
||||
assert.equal(awarenessEchoActive(t0), false);
|
||||
clearAwarenessEchoField();
|
||||
assert.equal(awarenessEchoActive(t0), false);
|
||||
});
|
||||
|
||||
test('near the subject an echo is no brighter than the fading-in front', () => {
|
||||
const fading = { ...pulse, fadeInFraction: 0.08 };
|
||||
const at = (distanceM) =>
|
||||
awarenessEchoStrength(arrival(distanceM), distanceM, R, fading, echo);
|
||||
// Born at 5 km, the front reaches full strength 8% of the way out.
|
||||
const full = R * (fading.startFraction + 0.08 * (1 - fading.startFraction));
|
||||
assert.equal(at(R * fading.startFraction), 0);
|
||||
const halfway = (R * fading.startFraction + full) / 2;
|
||||
assert.ok(Math.abs(at(halfway) - 0.5) < 0.01, `${at(halfway)}`);
|
||||
assert.ok(at(full + 1) > 0.99);
|
||||
assert.ok(
|
||||
at(R * 0.999) > 0.99,
|
||||
'the rim, and so the ring flash, is unchanged',
|
||||
);
|
||||
});
|
||||
@@ -421,6 +421,49 @@ const COUNTRY_TARGET_ALIASES = new Map([
|
||||
['drc', 'democratic republic of the congo'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Country aliases that are another name for the very same unit. "Great
|
||||
* Britain" and "Britain" are islands, "America" is also two continents and
|
||||
* "Korea" two countries, so those find a country without naming it.
|
||||
*/
|
||||
const COUNTRY_IDENTITY_ALIASES = new Set([
|
||||
'usa',
|
||||
'us',
|
||||
'u s',
|
||||
'u s a',
|
||||
'united states',
|
||||
'uk',
|
||||
'u k',
|
||||
'russian federation',
|
||||
'czechia',
|
||||
'tanzania',
|
||||
'drc',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The unit an ask names in the matcher's own terms, when its words differ
|
||||
* from the unit's name: "State of Texas" names Texas at admin level 1, and
|
||||
* "USA" or "Country of USA" names United States of America. Null when the
|
||||
* ask is just a name. Names are folded with `normalizeAdminName`.
|
||||
* @param {string} text
|
||||
* @returns {{name: string, level: 'country'|'admin1'}|null}
|
||||
*/
|
||||
export function adminQueryIdentity(text) {
|
||||
const parsed = parseAdminQuery(text);
|
||||
if (!parsed?.name) return null;
|
||||
if (parsed.kind === 'state' && parsed.form === 'prefix')
|
||||
return { name: parsed.name, level: 'admin1' };
|
||||
if (parsed.kind !== 'country' && parsed.kind !== 'any') return null;
|
||||
if (COUNTRY_IDENTITY_ALIASES.has(parsed.name))
|
||||
return {
|
||||
name: COUNTRY_TARGET_ALIASES.get(parsed.name),
|
||||
level: 'country',
|
||||
};
|
||||
return parsed.kind === 'country'
|
||||
? { name: parsed.name, level: 'country' }
|
||||
: null;
|
||||
}
|
||||
|
||||
const COUNTRY_ALIASES = new Map([
|
||||
...COUNTRY_TARGET_ALIASES,
|
||||
['england', 'united kingdom'],
|
||||
@@ -796,6 +839,35 @@ export async function findAdminArea(query, { near = null } = {}) {
|
||||
return toResult(ranked[0], candidates.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every prominent unit an ask's words could mean — countries, and states or
|
||||
* provinces that would answer a bare name (see `stateCandidates`) — for a
|
||||
* caller that asks the user which one ("Georgia", "Punjab") instead of
|
||||
* letting the camera decide. Qualifiers narrow the list as in
|
||||
* `findAdminArea`; minor units ("Kent", "Santa Barbara") are left out.
|
||||
*
|
||||
* @param {string} query
|
||||
* @returns {Promise<AdminArea[]>} Largest first; empty when none.
|
||||
*/
|
||||
export async function findAdminCandidates(query) {
|
||||
const parsed = parseAdminQuery(query);
|
||||
if (!parsed?.name || parsed.kind === 'county') return [];
|
||||
const prominent = ({ entry }) =>
|
||||
entry.kind === 'country' ||
|
||||
(entry.feature.rank ?? Infinity) <= BARE_NAME_MAX_RANK ||
|
||||
Boolean(entry.feature.cityState);
|
||||
const candidates = [
|
||||
...(await countryCandidates(parsed)),
|
||||
...(parsed.kind === 'country'
|
||||
? []
|
||||
: await stateCandidates(parsed, { allowAmbiguous: true })),
|
||||
].filter(prominent);
|
||||
const seen = new Set();
|
||||
return rank(candidates, null)
|
||||
.filter(({ entry }) => !seen.has(entry) && seen.add(entry))
|
||||
.map((scored) => toResult(scored, candidates.length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocoder-confirmed lookup: the geocoder typed a country, state or
|
||||
* county, so the unit must carry one of `names` AND contain the geocoded
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
decodeRing,
|
||||
findAdminArea,
|
||||
findAdminAreaAt,
|
||||
findAdminCandidates,
|
||||
normalizeAdminName,
|
||||
packLoads,
|
||||
parseAdminQuery,
|
||||
@@ -247,6 +248,7 @@ test('geocoder-confirmed lookup: the named unit that contains the point', async
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(await findAdminCandidates('Bagmati Province'), []);
|
||||
// A county the geocoder typed, named without the county word.
|
||||
const travis = await findAdminAreaAt(
|
||||
['Travis', 'Travis County'],
|
||||
|
||||
+230
-47
@@ -18,7 +18,11 @@
|
||||
*
|
||||
* Providers (injected — keeps the engine pure and node-testable):
|
||||
* getRecords(layerKey) → Array<record> (layer accessor snapshot)
|
||||
* resolveRegionRing(name, signal?) → Promise<{ring, name}|{error:'region-timeout'}|null>
|
||||
* resolveRegionRing(name, {signal}?) → Promise<{name, geometry|ring, prepared?, source?}
|
||||
* |{error:'region-timeout'}|{needsClarification, candidates}|null>
|
||||
* (geometry = MultiPolygon coordinates; islands and holes all count)
|
||||
* resolveAreaScope(scope) → Promise<{ok:true, area:{areaId, name, prepared,
|
||||
* source, approximate}}|{ok:false, code, error}> (area/drawn/annotation)
|
||||
* getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived)
|
||||
*
|
||||
* @module data/analystEngine
|
||||
@@ -26,6 +30,7 @@
|
||||
|
||||
import { feedProvenanceEnvelope } from './layerSnapshot.js';
|
||||
import { pointInRing } from './naturalEarthRegions.js';
|
||||
import { pointInPreparedArea, prepareArea } from './areaGeometry.js';
|
||||
import { VOICE_LAYER_MANIFEST, voiceLayer } from '../voice/layerManifest.js';
|
||||
import { greatCircleKm } from '../geoDistance.js';
|
||||
|
||||
@@ -67,6 +72,9 @@ const ANALYST_SCOPE_KINDS = Object.freeze([
|
||||
'region',
|
||||
'radius',
|
||||
'anywhere',
|
||||
'area',
|
||||
'drawn',
|
||||
'annotation',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -104,9 +112,14 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
|
||||
}
|
||||
|
||||
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere', …}. */
|
||||
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere'|'area', …}. */
|
||||
export function applyScope(records, scope, resolved) {
|
||||
if (!scope || scope.kind === 'anywhere') return records;
|
||||
if (resolved?.prepared) {
|
||||
return records.filter((r) =>
|
||||
pointInPreparedArea(resolved.prepared, r.lat, r.lon),
|
||||
);
|
||||
}
|
||||
if (scope.kind === 'region' && resolved?.ring) {
|
||||
return records.filter(
|
||||
(r) =>
|
||||
@@ -320,6 +333,24 @@ function normalizeSpec(spec, layerKeys) {
|
||||
return {
|
||||
refusal: refusal('BAD_SCOPE', 'A region scope needs a place name.'),
|
||||
};
|
||||
if (
|
||||
kind === 'area' &&
|
||||
(typeof scope.areaId !== 'string' || !scope.areaId.trim())
|
||||
)
|
||||
return {
|
||||
refusal: refusal(
|
||||
'BAD_SCOPE',
|
||||
'An area scope needs the areaId resolve_area returned.',
|
||||
),
|
||||
};
|
||||
if (
|
||||
kind === 'annotation' &&
|
||||
scope.id !== undefined &&
|
||||
(typeof scope.id !== 'string' || !scope.id.trim())
|
||||
)
|
||||
return {
|
||||
refusal: refusal('BAD_SCOPE', 'scope.id must be an annotation id.'),
|
||||
};
|
||||
if (scope.center !== undefined && scope.center !== null) {
|
||||
if (!validLat(scope.center.lat) || !validLon(scope.center.lon))
|
||||
return {
|
||||
@@ -456,7 +487,16 @@ const ANSWERABLE = new Set(['ok', 'empty']);
|
||||
|
||||
/** Rows per scan slice before yielding to the page. */
|
||||
const SCAN_CHUNK = 20_000;
|
||||
/**
|
||||
* A slice also ends after this long: a row inside a large area pays a ray
|
||||
* cast over every edge (a 6,000-vertex outline takes ~200 ms per 20,000 rows),
|
||||
* so the row count alone would still make long tasks.
|
||||
*/
|
||||
const SCAN_BUDGET_MS = 8;
|
||||
/** Rows between clock checks. */
|
||||
const SCAN_CLOCK_EVERY = 256;
|
||||
const yieldToPage = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const clockMs = () => globalThis.performance?.now?.() ?? Date.now();
|
||||
|
||||
/**
|
||||
* Compile the filters into one predicate over (layerKey, row).
|
||||
@@ -597,12 +637,19 @@ export async function selectAnalystRows(
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
let seen = 0;
|
||||
let sliceStart = clockMs();
|
||||
for (const { key, rows } of groups) {
|
||||
const kept = [];
|
||||
for (const row of rows) {
|
||||
if (++seen % SCAN_CHUNK === 0) {
|
||||
seen += 1;
|
||||
if (
|
||||
seen % SCAN_CHUNK === 0 ||
|
||||
(seen % SCAN_CLOCK_EVERY === 0 &&
|
||||
clockMs() - sliceStart >= SCAN_BUDGET_MS)
|
||||
) {
|
||||
await yieldToPage();
|
||||
if (!isCurrent()) return null;
|
||||
sliceStart = clockMs();
|
||||
}
|
||||
if (inScope && !inScope(row)) continue;
|
||||
if (!passes(key, row)) continue;
|
||||
@@ -650,9 +697,8 @@ export function createAnalystEngine(providers) {
|
||||
/**
|
||||
* Run one query.
|
||||
* @param {object} spec Query arguments (see the analyst_query tool).
|
||||
* @param {{isCurrent?: () => boolean, signal?: AbortSignal,
|
||||
* beforeCommit?: (result: object) => Promise<object|void>}} [options]
|
||||
* A query that is no longer current
|
||||
* @param {{signal?: AbortSignal, isCurrent?: () => boolean, beforeCommit?: (result: object) =>
|
||||
* Promise<object|void>}} [options] A query that is no longer current
|
||||
* (cancelled or superseded while awaiting a boundary or between scan
|
||||
* slices) returns CANCELLED and never replaces the follow-up memory.
|
||||
* `beforeCommit` lets a caller finish required async projection work and
|
||||
@@ -662,7 +708,7 @@ export function createAnalystEngine(providers) {
|
||||
*/
|
||||
async function query(
|
||||
spec = {},
|
||||
{ isCurrent = () => true, signal = undefined, beforeCommit = null } = {},
|
||||
{ signal = null, isCurrent = () => true, beforeCommit = null } = {},
|
||||
) {
|
||||
const cancelled = () =>
|
||||
refusal('CANCELLED', 'This query was superseded.', { cancelled: true });
|
||||
@@ -673,6 +719,7 @@ export function createAnalystEngine(providers) {
|
||||
{ coverage: { layersQueried: [], scope: 'none' } },
|
||||
);
|
||||
const followUp = Boolean(spec.followUp);
|
||||
const inheritsRememberedScope = followUp && spec.scope == null;
|
||||
// A follow-up re-filters the remembered set. Layers named explicitly
|
||||
// must be that set: "those hospitals" after an aircraft answer is a new
|
||||
// question, never a filter over the old aircraft.
|
||||
@@ -815,25 +862,37 @@ export function createAnalystEngine(providers) {
|
||||
}
|
||||
|
||||
// 2) Spatial scope
|
||||
const rememberedScope =
|
||||
followUp && !spec.scope ? lastResult.scopePresentation || null : null;
|
||||
const rememberedDistanceOf =
|
||||
followUp && !spec.scope ? lastResult.distanceOf || null : null;
|
||||
const rememberedPresentation = inheritsRememberedScope
|
||||
? lastResult.presentation || null
|
||||
: null;
|
||||
const rememberedCoverageNote =
|
||||
rememberedPresentation?.coverageNote ||
|
||||
(inheritsRememberedScope ? lastResult.coverage?.note : null);
|
||||
const rememberedDistanceOf = inheritsRememberedScope
|
||||
? lastResult.distanceOf || null
|
||||
: null;
|
||||
let resolvedScope = null;
|
||||
let scopeNote = rememberedScope?.coverage || 'anywhere';
|
||||
let scopeSource = rememberedScope?.source || null;
|
||||
let scopeNote =
|
||||
rememberedPresentation?.coverageScope ||
|
||||
(inheritsRememberedScope ? lastResult.coverage?.scope : null) ||
|
||||
'anywhere';
|
||||
let scopeSource =
|
||||
rememberedPresentation?.coverageScopeSource ||
|
||||
(inheritsRememberedScope ? lastResult.coverage?.scopeSource : null) ||
|
||||
null;
|
||||
// Human phrasing for the same scope, so every spoken count can name what it
|
||||
// measured ("8 in view", "about 30 within 250 km of Austin") instead of
|
||||
// arriving as a bare number that contradicts the panel.
|
||||
let scopeLabel = rememberedScope?.label || 'anywhere in the loaded data';
|
||||
let scopeDetail = rememberedScope?.detail || scopeLabel;
|
||||
let scopeLabel =
|
||||
rememberedPresentation?.scopeLabel || 'anywhere in the loaded data';
|
||||
let scopeDetail = rememberedPresentation?.display?.scope || scopeLabel;
|
||||
// A follow-up re-filters a set that was already scoped, so it only narrows
|
||||
// further when a new scope is given.
|
||||
const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' };
|
||||
if (scope.kind === 'region') {
|
||||
let region;
|
||||
try {
|
||||
region = await providers.resolveRegionRing(scope.name, signal);
|
||||
region = await providers.resolveRegionRing(scope.name, { signal });
|
||||
} catch (error) {
|
||||
// Cancellation belongs to the query owner, not an error's name: a
|
||||
// resource-local AbortError from a current lookup still propagates.
|
||||
@@ -849,7 +908,32 @@ export function createAnalystEngine(providers) {
|
||||
coverage: { layersQueried, scope: `region:${scope.name}:timeout` },
|
||||
};
|
||||
}
|
||||
if (!region?.ring) {
|
||||
if (region?.needsClarification) {
|
||||
return refusal(
|
||||
'NEEDS_CLARIFICATION',
|
||||
region.error ||
|
||||
`"${scope.name}" names more than one place — ask which one.`,
|
||||
{
|
||||
needsClarification: true,
|
||||
query: scope.name,
|
||||
candidates: region.candidates || [],
|
||||
// The way back once the user picks: count over that area.
|
||||
then: 'After the user picks, call resolve_area with that candidateId, then analyst_query with scope {kind:"area", areaId}.',
|
||||
coverage: {
|
||||
layersQueried,
|
||||
scope: `region:${scope.name}:ambiguous`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
const prepared =
|
||||
region?.prepared ||
|
||||
(region?.geometry
|
||||
? prepareArea(region.geometry)
|
||||
: region?.ring
|
||||
? prepareArea([[region.ring]])
|
||||
: null);
|
||||
if (!prepared) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'REGION_UNRESOLVED',
|
||||
@@ -857,11 +941,39 @@ export function createAnalystEngine(providers) {
|
||||
coverage: { layersQueried, scope: `region:${scope.name}:unresolved` },
|
||||
};
|
||||
}
|
||||
resolvedScope = region;
|
||||
resolvedScope = { ...region, prepared };
|
||||
scopeNote = `region:${region.name}`;
|
||||
scopeSource = region.source || null;
|
||||
scopeLabel = `over ${region.name}`;
|
||||
scopeDetail = scopeLabel;
|
||||
const regionSource = region.sourceLabel || region.source;
|
||||
scopeDetail = `${scopeLabel}${region.approximate ? ' (approximate outline)' : ''}${regionSource ? ` · ${regionSource}` : ''}`;
|
||||
} else if (
|
||||
scope.kind === 'area' ||
|
||||
scope.kind === 'drawn' ||
|
||||
scope.kind === 'annotation'
|
||||
) {
|
||||
const found = providers.resolveAreaScope
|
||||
? await providers.resolveAreaScope(scope)
|
||||
: {
|
||||
ok: false,
|
||||
code: 'AREA_UNAVAILABLE',
|
||||
error: 'Area scopes are not available here.',
|
||||
};
|
||||
if (!isCurrent()) return cancelled();
|
||||
if (!found?.ok || !found.area?.prepared) {
|
||||
return refusal(
|
||||
found?.code || 'AREA_UNKNOWN',
|
||||
found?.error || 'That area is not available.',
|
||||
{ coverage: { layersQueried, scope: `${scope.kind}:unresolved` } },
|
||||
);
|
||||
}
|
||||
const area = found.area;
|
||||
resolvedScope = { prepared: area.prepared, name: area.name };
|
||||
scopeNote = `area:${area.areaId || scope.kind}`;
|
||||
scopeLabel = `inside ${area.name}`;
|
||||
const sourceLabel = area.sourceLabel || area.source;
|
||||
scopeDetail = `${scopeLabel}${area.approximate ? ' (approximate outline)' : ''}${sourceLabel ? ` · ${sourceLabel}` : ''}`;
|
||||
if (area.areaId) resolvedScope.areaId = area.areaId;
|
||||
} else if (scope.kind === 'radius') {
|
||||
// An explicit center always wins. Otherwise, when Contacts is active its
|
||||
// SUBJECT is the centre the operator is actually reasoning about: the
|
||||
@@ -906,25 +1018,21 @@ export function createAnalystEngine(providers) {
|
||||
scopeLabel = 'in view';
|
||||
scopeDetail = `within ${Math.round(view.viewRadiusKm)} km of the view centre`;
|
||||
}
|
||||
const inScope =
|
||||
scope.kind === 'region' && resolvedScope?.ring
|
||||
const inScope = resolvedScope?.prepared
|
||||
? (row) => pointInPreparedArea(resolvedScope.prepared, row.lat, row.lon)
|
||||
: (scope.kind === 'radius' || scope.kind === 'view') &&
|
||||
resolvedScope?.center &&
|
||||
Number.isFinite(resolvedScope.km)
|
||||
? (row) =>
|
||||
Number.isFinite(row.lat) &&
|
||||
Number.isFinite(row.lon) &&
|
||||
pointInRing(resolvedScope.ring, row.lat, row.lon)
|
||||
: (scope.kind === 'radius' || scope.kind === 'view') &&
|
||||
resolvedScope?.center &&
|
||||
Number.isFinite(resolvedScope.km)
|
||||
? (row) =>
|
||||
Number.isFinite(row.lat) &&
|
||||
Number.isFinite(row.lon) &&
|
||||
haversineKm(
|
||||
resolvedScope.center.lat,
|
||||
resolvedScope.center.lon,
|
||||
row.lat,
|
||||
row.lon,
|
||||
) <= resolvedScope.km
|
||||
: null;
|
||||
haversineKm(
|
||||
resolvedScope.center.lat,
|
||||
resolvedScope.center.lon,
|
||||
row.lat,
|
||||
row.lon,
|
||||
) <= resolvedScope.km
|
||||
: null;
|
||||
|
||||
// 3) Filter, count and rank in one pass, yielding between slices.
|
||||
const selection = await selectAnalystRows(groups, {
|
||||
@@ -992,15 +1100,25 @@ export function createAnalystEngine(providers) {
|
||||
// For on-screen text; the model speaks the count and scopeLabel only.
|
||||
display: {
|
||||
scope: scopeDetail,
|
||||
...(windows.length ? { window: windows.join('; ') } : {}),
|
||||
...(caveats.length ? { caveat: caveats.join('; ') } : {}),
|
||||
...(rememberedPresentation?.display?.window || windows.length
|
||||
? {
|
||||
window:
|
||||
rememberedPresentation?.display?.window || windows.join('; '),
|
||||
}
|
||||
: {}),
|
||||
...(rememberedPresentation?.display?.caveat || caveats.length
|
||||
? {
|
||||
caveat:
|
||||
rememberedPresentation?.display?.caveat || caveats.join('; '),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
coverage: {
|
||||
layersQueried,
|
||||
records: totals,
|
||||
scope: scopeNote,
|
||||
...(scopeSource ? { scopeSource } : {}),
|
||||
...(rememberedScope?.note ? { note: rememberedScope.note } : {}),
|
||||
...(rememberedCoverageNote ? { note: rememberedCoverageNote } : {}),
|
||||
...(queriedSnapshots.length
|
||||
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
|
||||
: {}),
|
||||
@@ -1010,37 +1128,102 @@ export function createAnalystEngine(providers) {
|
||||
// than implying a view-centred answer.
|
||||
...(resolvedScope?.centeredOn
|
||||
? { centeredOn: resolvedScope.centeredOn }
|
||||
: rememberedPresentation?.centeredOn
|
||||
? { centeredOn: rememberedPresentation.centeredOn }
|
||||
: {}),
|
||||
...(rememberedPresentation?.window
|
||||
? { window: structuredClone(rememberedPresentation.window) }
|
||||
: {}),
|
||||
...(resolvedScope?.areaId
|
||||
? { areaId: resolvedScope.areaId }
|
||||
: rememberedPresentation?.areaId
|
||||
? { areaId: rememberedPresentation.areaId }
|
||||
: {}),
|
||||
};
|
||||
const memoryOverride =
|
||||
typeof beforeCommit === 'function'
|
||||
? (await beforeCommit(result)) || null
|
||||
: null;
|
||||
if (!isCurrent()) return cancelled();
|
||||
const committedCoverage = memoryOverride?.coverage || result.coverage;
|
||||
lastResult = {
|
||||
matched: memoryOverride?.matched || selection.matched,
|
||||
distanceOf: memoryOverride?.distanceOf || rememberedDistanceOf,
|
||||
layerKeys: [...(memoryOverride?.layerKeys || layers)],
|
||||
coverage: committedCoverage,
|
||||
coverage: memoryOverride?.coverage || result.coverage,
|
||||
unanswered: [
|
||||
...(memoryOverride?.unanswered === undefined
|
||||
? unanswered
|
||||
: memoryOverride.unanswered),
|
||||
],
|
||||
scopePresentation: {
|
||||
label: memoryOverride?.scopeLabel || result.scopeLabel,
|
||||
detail: memoryOverride?.scopeDetail || result.display.scope,
|
||||
coverage: committedCoverage.scope,
|
||||
source: committedCoverage.scopeSource || null,
|
||||
note: committedCoverage.note || null,
|
||||
},
|
||||
presentation: structuredClone(
|
||||
memoryOverride?.presentation || {
|
||||
scopeLabel: result.scopeLabel,
|
||||
display: result.display,
|
||||
coverageScope: result.coverage?.scope,
|
||||
...(result.coverage?.scopeSource
|
||||
? { coverageScopeSource: result.coverage.scopeSource }
|
||||
: {}),
|
||||
...(result.coverage?.note
|
||||
? { coverageNote: result.coverage.note }
|
||||
: {}),
|
||||
...(result.centeredOn ? { centeredOn: result.centeredOn } : {}),
|
||||
...(result.window ? { window: result.window } : {}),
|
||||
...(result.areaId ? { areaId: result.areaId } : {}),
|
||||
},
|
||||
),
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
/**
|
||||
* Make another tool's result set "the last answer" (osm_query's places),
|
||||
* so a follow-up filters exactly what the user just heard about.
|
||||
* @param {string} layerKey Queryable layer the rows belong to.
|
||||
* @param {Array<object>} rows Records in that layer's analyst shape.
|
||||
* @param {{scope?: string, presentation?: object|null, truncated?: boolean}} [options]
|
||||
* `presentation` is how the set was scoped ("inside Kathmandu"), so a
|
||||
* follow-up names the same scope instead of "anywhere". `truncated`
|
||||
* says the set is only the first part of a larger one, so follow-ups
|
||||
* stay incomplete (a count is a floor) rather than claiming the whole.
|
||||
*/
|
||||
remember(
|
||||
layerKey,
|
||||
rows,
|
||||
{ scope = 'handoff', presentation = null, truncated = false } = {},
|
||||
) {
|
||||
if (!ANALYST_LAYERS[layerKey] || !Array.isArray(rows)) return false;
|
||||
const capped = truncated === true;
|
||||
// A capped set's true size is unknown.
|
||||
const total = capped ? null : rows.length;
|
||||
lastResult = {
|
||||
...(presentation
|
||||
? { presentation: structuredClone(presentation) }
|
||||
: {}),
|
||||
matched: [{ key: layerKey, rows: rows.slice() }],
|
||||
layerKeys: [layerKey],
|
||||
coverage: {
|
||||
layersQueried: [
|
||||
{
|
||||
layerKey,
|
||||
status: rows.length ? 'ok' : 'empty',
|
||||
returned: rows.length,
|
||||
total,
|
||||
truncated: capped,
|
||||
},
|
||||
],
|
||||
records: {
|
||||
returned: rows.length,
|
||||
total,
|
||||
truncated: capped,
|
||||
},
|
||||
scope,
|
||||
followUp: false,
|
||||
},
|
||||
};
|
||||
return true;
|
||||
},
|
||||
reset() {
|
||||
lastResult = null;
|
||||
},
|
||||
|
||||
@@ -275,14 +275,18 @@ for (const [label, committedScope, scopeSource] of [
|
||||
const { scopeSource: _originalSource, ...coverage } = result.coverage;
|
||||
return {
|
||||
matched: [{ key: 'flights', rows: [FLIGHTS[1]] }],
|
||||
scopeLabel: 'the presented cohort',
|
||||
scopeDetail: 'the presented cohort window',
|
||||
coverage: {
|
||||
...coverage,
|
||||
scope: committedScope,
|
||||
...(scopeSource ? { scopeSource } : {}),
|
||||
feedProvenance: { layers: [committedSnapshot] },
|
||||
},
|
||||
presentation: {
|
||||
scopeLabel: 'the presented cohort',
|
||||
display: { scope: 'the presented cohort window' },
|
||||
coverageScope: committedScope,
|
||||
...(scopeSource ? { coverageScopeSource: scopeSource } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -436,6 +440,87 @@ test('analyst: failed pre-commit work cannot replace follow-up memory', async ()
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
|
||||
});
|
||||
|
||||
test('analyst: follow-ups preserve partial and unanswered coverage', async () => {
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: (key) => (key === 'flights' ? FLIGHTS : []),
|
||||
getLayerSnapshot: (key) => ({
|
||||
enabled: key === 'flights',
|
||||
feedState: key === 'flights' ? 'nominal' : 'off',
|
||||
}),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
const first = await eng.query({
|
||||
layers: ['flights', 'military'],
|
||||
scope: { kind: 'anywhere' },
|
||||
});
|
||||
assert.equal(first.partial, true);
|
||||
assert.deepEqual(first.unanswered, ['military']);
|
||||
|
||||
const followUp = await eng.query({ followUp: true, limit: 1 });
|
||||
assert.equal(followUp.partial, true);
|
||||
assert.deepEqual(followUp.unanswered, ['military']);
|
||||
assert.deepEqual(
|
||||
followUp.coverage.layersQueried.map(({ layerKey, status }) => [
|
||||
layerKey,
|
||||
status,
|
||||
]),
|
||||
first.coverage.layersQueried.map(({ layerKey, status }) => [
|
||||
layerKey,
|
||||
status,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst: cancelled pre-commit work cannot replace follow-up memory', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
|
||||
});
|
||||
let current = true;
|
||||
const stale = await eng.query(
|
||||
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
|
||||
{
|
||||
isCurrent: () => current,
|
||||
beforeCommit: async () => {
|
||||
current = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(stale.code, 'CANCELLED');
|
||||
const followUp = await eng.query({ followUp: true });
|
||||
assert.deepEqual(
|
||||
followUp.items.map((item) => item.id),
|
||||
['SWA1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst: failed pre-commit work cannot replace follow-up memory', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
|
||||
});
|
||||
await assert.rejects(
|
||||
eng.query(
|
||||
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
|
||||
{
|
||||
beforeCommit: async () => {
|
||||
throw new Error('projection failed');
|
||||
},
|
||||
},
|
||||
),
|
||||
/projection failed/,
|
||||
);
|
||||
const followUp = await eng.query({ followUp: true });
|
||||
assert.deepEqual(
|
||||
followUp.items.map((item) => item.id),
|
||||
['SWA1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst: unresolved region is an honest failure, not empty success', async () => {
|
||||
const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'region', name: 'Atlantis' } });
|
||||
assert.equal(r.ok, false);
|
||||
@@ -460,7 +545,7 @@ test('analyst: region lookup receives and obeys the owning action signal', async
|
||||
let receivedSignal = null;
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (_name, signal) => {
|
||||
resolveRegionRing: async (_name, { signal }) => {
|
||||
receivedSignal = signal;
|
||||
await new Promise((resolve) =>
|
||||
signal.addEventListener('abort', resolve, { once: true }),
|
||||
@@ -514,7 +599,7 @@ for (const [label, reason, trackAuthority] of [
|
||||
};
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: (name, signal) => resolveRegionRingForQuery(
|
||||
resolveRegionRing: (name, { signal } = {}) => resolveRegionRingForQuery(
|
||||
name, signal, placeSearch, { budgetMs: Infinity },
|
||||
),
|
||||
});
|
||||
@@ -720,7 +805,9 @@ test('unknown fields, operators, scopes and wrong-typed values are refused with
|
||||
assert.equal(num.code, 'BAD_VALUE');
|
||||
const scope = await engine.query({ layers: ['flights'], scope: { kind: 'polygon' } });
|
||||
assert.equal(scope.code, 'UNKNOWN_SCOPE');
|
||||
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere']);
|
||||
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere', 'area', 'drawn', 'annotation']);
|
||||
const noId = await engine.query({ layers: ['flights'], scope: { kind: 'area' } });
|
||||
assert.equal(noId.code, 'BAD_SCOPE', 'an area scope needs its areaId');
|
||||
const sort = await engine.query({ layers: ['flights'], sortBy: 'loudness' });
|
||||
assert.equal(sort.code, 'UNKNOWN_FIELD');
|
||||
assert.equal(engine.hasMemory(), false, 'refusals never become follow-up memory');
|
||||
@@ -963,3 +1050,193 @@ test('analyst: a follow-up naming other layers than the last answer is refused',
|
||||
assert.equal(r.code, 'FOLLOW_UP_MISMATCH');
|
||||
assert.equal(engine.hasMemory(), true, 'the earlier answer is still the last answer');
|
||||
});
|
||||
|
||||
// ── Area handles: region multipolygons and the area / drawn / annotation scopes ──
|
||||
|
||||
const box = (w, s, e, n) => [[w, s], [e, s], [e, n], [w, n], [w, s]];
|
||||
|
||||
function makeAreaEngine(overrides = {}) {
|
||||
return createAnalystEngine({
|
||||
getRecords: (key) => ({ flights: FLIGHTS, 'local-firms': FIRES }[key] || []),
|
||||
resolveRegionRing: async () => null,
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: a region counts every part of a multipolygon and skips its holes', async () => {
|
||||
// Main part around Austin with a hole over the airport ground traffic, and a
|
||||
// separate island over Oregon: the old largest-ring shortcut dropped N123.
|
||||
const engine = makeAreaEngine({
|
||||
resolveRegionRing: async () => ({
|
||||
name: 'Archipelago',
|
||||
geometry: [
|
||||
[box(-100, 28, -94, 33), box(-97.7, 30.1, -97.6, 30.25)],
|
||||
[box(-123, 44, -121, 46)],
|
||||
],
|
||||
source: 'osm',
|
||||
}),
|
||||
});
|
||||
const r = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Archipelago' }, limit: 10 });
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.items.map((i) => i.id).sort(), ['N123', 'RCH01']);
|
||||
assert.equal(r.count, 2, 'SWA1 and GND1 sit in the hole');
|
||||
assert.match(r.display.scope, /over Archipelago · osm/);
|
||||
});
|
||||
|
||||
test('analyst: an ambiguous region asks instead of counting', async () => {
|
||||
const engine = makeAreaEngine({
|
||||
resolveRegionRing: async () => ({
|
||||
ok: false,
|
||||
needsClarification: true,
|
||||
candidates: [{ candidateId: 'osm:r1', name: 'Georgia', level: 'country' }],
|
||||
error: '"Georgia" matches 2 places — ask which one.',
|
||||
}),
|
||||
});
|
||||
const r = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Georgia' } });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'NEEDS_CLARIFICATION');
|
||||
assert.equal(r.candidates.length, 1);
|
||||
assert.match(r.then, /resolve_area.*candidateId.*analyst_query.*kind.*area/, 'the model gets a route back');
|
||||
assert.equal(engine.hasMemory(), false);
|
||||
});
|
||||
|
||||
test('analyst: region lookup receives the caller lifetime signal', async () => {
|
||||
const controller = new AbortController();
|
||||
let received;
|
||||
const engine = makeAreaEngine({
|
||||
resolveRegionRing: async (_name, options) => {
|
||||
received = options?.signal;
|
||||
return { error: 'region-timeout' };
|
||||
},
|
||||
});
|
||||
const result = await engine.query(
|
||||
{
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Signal Region' },
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
assert.equal(received, controller.signal);
|
||||
assert.equal(result.code, 'region-timeout');
|
||||
});
|
||||
|
||||
test('analyst: an area scope counts inside the stored handle and names it', async () => {
|
||||
const { prepareArea } = await import('./areaGeometry.js');
|
||||
const prepared = prepareArea([[box(-99, 30, -97.5, 31)]]);
|
||||
const seen = [];
|
||||
const engine = makeAreaEngine({
|
||||
resolveAreaScope: async (scope) => {
|
||||
seen.push(scope);
|
||||
if (scope.kind === 'area' && scope.areaId === 'osm:r42')
|
||||
return { ok: true, area: { areaId: 'osm:r42', name: 'Hill Country', prepared, source: 'osm' } };
|
||||
if (scope.kind === 'drawn')
|
||||
return { ok: true, area: { areaId: 'anno:anno-7', name: 'the drawn area', prepared, source: 'drawn' } };
|
||||
return { ok: false, code: 'AREA_UNKNOWN', error: 'No such area' };
|
||||
},
|
||||
});
|
||||
const fires = await engine.query({ layers: ['local-firms'], scope: { kind: 'area', areaId: 'osm:r42' } });
|
||||
assert.equal(fires.ok, true);
|
||||
assert.equal(fires.count, 2);
|
||||
assert.equal(fires.scopeLabel, 'inside Hill Country');
|
||||
assert.equal(fires.areaId, 'osm:r42');
|
||||
assert.equal(fires.coverage.scope, 'area:osm:r42');
|
||||
const followUp = await engine.query({ followUp: true, limit: 1 });
|
||||
assert.equal(followUp.scopeLabel, 'inside Hill Country');
|
||||
assert.match(followUp.display.scope, /inside Hill Country/);
|
||||
assert.equal(followUp.coverage.scope, 'area:osm:r42');
|
||||
assert.equal(followUp.areaId, 'osm:r42');
|
||||
const drawn = await engine.query({ layers: ['flights'], scope: { kind: 'drawn' } });
|
||||
assert.equal(drawn.ok, true);
|
||||
assert.equal(drawn.count, 2, 'SWA1 and GND1 sit inside the drawn box');
|
||||
const missing = await engine.query({ layers: ['flights'], scope: { kind: 'area', areaId: 'osm:r0' } });
|
||||
assert.equal(missing.ok, false);
|
||||
assert.equal(missing.code, 'AREA_UNKNOWN');
|
||||
assert.deepEqual(seen.map((s) => s.kind), ['area', 'drawn', 'area']);
|
||||
});
|
||||
|
||||
test('analyst: an area scope superseded while resolving is cancelled and leaves no memory', async () => {
|
||||
const { prepareArea } = await import('./areaGeometry.js');
|
||||
let current = true;
|
||||
const engine = makeAreaEngine({
|
||||
resolveAreaScope: async () => {
|
||||
current = false; // a newer turn arrived while the area was looked up
|
||||
return { ok: true, area: { name: 'X', prepared: prepareArea([[box(-100, 28, -94, 33)]]) } };
|
||||
},
|
||||
});
|
||||
const r = await engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'annotation' } },
|
||||
{ isCurrent: () => current },
|
||||
);
|
||||
assert.equal(r.code, 'CANCELLED');
|
||||
assert.equal(engine.hasMemory(), false);
|
||||
});
|
||||
|
||||
test('analyst: without an area provider the area scopes refuse plainly', async () => {
|
||||
const r = await makeAreaEngine().query({ layers: ['flights'], scope: { kind: 'drawn' } });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'AREA_UNAVAILABLE');
|
||||
});
|
||||
|
||||
test('a follow-up on handed-off places keeps the area they were found in', async () => {
|
||||
const rows = [
|
||||
{ id: 'node/1', lat: 27.7, lon: 85.3, name: 'Bir Hospital', kind: 'hospital', emergency: 'yes' },
|
||||
{ id: 'node/2', lat: 27.71, lon: 85.31, name: 'Teaching Hospital', kind: 'hospital', emergency: 'no' },
|
||||
];
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => (key === 'osm-places' ? rows : []),
|
||||
resolveRegionRing: async () => null,
|
||||
getViewContext: () => ({ lat: 27.7, lon: 85.3, viewRadiusKm: 20 }),
|
||||
});
|
||||
assert.equal(
|
||||
engine.remember('osm-places', rows, {
|
||||
presentation: { scopeLabel: 'inside Kathmandu', coverageScope: 'area:area-1', areaId: 'area-1' },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const result = await engine.query({
|
||||
layers: ['osm-places'],
|
||||
followUp: true,
|
||||
filters: [{ field: 'emergency', op: 'eq', value: 'yes' }],
|
||||
});
|
||||
assert.equal(result.count, 1);
|
||||
assert.equal(result.scopeLabel, 'inside Kathmandu');
|
||||
assert.equal(result.coverage.scope, 'area:area-1');
|
||||
});
|
||||
|
||||
test('a follow-up on a capped handed-off set is not complete', async () => {
|
||||
const rows = [
|
||||
{ id: 'node/1', lat: 27.7, lon: 85.3, name: 'Bir Hospital', kind: 'hospital', emergency: 'yes' },
|
||||
];
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => (key === 'osm-places' ? rows : []),
|
||||
resolveRegionRing: async () => null,
|
||||
getViewContext: () => ({ lat: 27.7, lon: 85.3, viewRadiusKm: 20 }),
|
||||
});
|
||||
engine.remember('osm-places', rows, { truncated: true });
|
||||
const result = await engine.query({ layers: ['osm-places'], followUp: true });
|
||||
assert.equal(result.count, 1);
|
||||
assert.equal(result.complete, false);
|
||||
assert.equal(result.coverage.records.truncated, true);
|
||||
assert.equal(result.coverage.records.total, null, 'the true size is unknown');
|
||||
});
|
||||
|
||||
test('a slow scan yields on a time budget, not only every 20,000 rows', async () => {
|
||||
// 4,000 rows that each cost ~0.05 ms to read: ~200 ms of work in one slice
|
||||
// under a row-count budget alone.
|
||||
const slow = () => {
|
||||
const until = performance.now() + 0.05;
|
||||
while (performance.now() < until);
|
||||
return 5;
|
||||
};
|
||||
const rows = Array.from({ length: 4_000 }, (_, i) => ({ id: `S${i}`, lat: 0, lon: 0, get frp() { return slow(); } }));
|
||||
const engine = createAnalystEngine({ getRecords: () => rows, getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }) });
|
||||
// Count the scan's yields (other timers run only when it yields), not wall
|
||||
// gaps, so a loaded machine cannot fail it.
|
||||
let ticks = 0;
|
||||
const timer = setInterval(() => ticks++, 0);
|
||||
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, filters: [{ field: 'frp', op: 'gt', value: 1 }] });
|
||||
clearInterval(timer);
|
||||
assert.equal(result.count, 4_000);
|
||||
assert.ok(ticks >= 5, `the scan yielded ${ticks} times in ~200 ms of work`);
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Area geometry for voice area handles: normalization, containment, size and
|
||||
* display simplification over full MultiPolygons (islands and holes kept).
|
||||
*
|
||||
* Coordinates follow GeoJSON: a MultiPolygon is `Array<Polygon>`, a Polygon is
|
||||
* `Array<Ring>` with the outer ring first and holes after it, a Ring is
|
||||
* `Array<[lon, lat]>`.
|
||||
*
|
||||
* Counting uses the geometry exactly as resolved. Display simplification
|
||||
* (`displayParts`) makes a separate copy for drawing and never feeds back into
|
||||
* containment.
|
||||
*
|
||||
* Antimeridian: a ring whose consecutive longitudes jump by more than 180° is
|
||||
* unwrapped into one continuous span (e.g. 170..190) when the jumps cancel
|
||||
* out. Containment then tests the point at lon, lon + 360 and lon − 360. A ring
|
||||
* that circles a pole (jumps that do not cancel) is used as given.
|
||||
*
|
||||
* Pure: no Cesium, DOM or network.
|
||||
* @module data/areaGeometry
|
||||
*/
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const toRad = (degrees) => (degrees * Math.PI) / 180;
|
||||
|
||||
const validPair = (p) =>
|
||||
Array.isArray(p) &&
|
||||
Number.isFinite(p[0]) &&
|
||||
Number.isFinite(p[1]) &&
|
||||
Math.abs(p[0]) <= 540 &&
|
||||
Math.abs(p[1]) <= 90;
|
||||
|
||||
const samePoint = (a, b, eps = 1e-9) =>
|
||||
Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps;
|
||||
|
||||
/** A ring as closed [lon, lat] pairs, or null with fewer than 3 distinct points. */
|
||||
function cleanRing(ring) {
|
||||
if (!Array.isArray(ring)) return null;
|
||||
const out = [];
|
||||
for (const p of ring) {
|
||||
if (!validPair(p)) continue;
|
||||
const pair = [Number(p[0]), Number(p[1])];
|
||||
if (out.length && samePoint(out[out.length - 1], pair)) continue;
|
||||
out.push(pair);
|
||||
}
|
||||
if (out.length && samePoint(out[0], out[out.length - 1])) out.pop();
|
||||
if (out.length < 3) return null;
|
||||
out.push([out[0][0], out[0][1]]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeoJSON Polygon/MultiPolygon geometry (or bare coordinates) → MultiPolygon
|
||||
* coordinates with closed rings; null when nothing usable remains. Degenerate
|
||||
* holes are dropped; a polygon whose outer ring is degenerate is dropped whole.
|
||||
* @param {object|Array} geometry
|
||||
* @returns {Array|null}
|
||||
*/
|
||||
export function normalizeMultiPolygon(geometry) {
|
||||
let polygons;
|
||||
if (Array.isArray(geometry)) {
|
||||
// Bare coordinates: a ring, a polygon or a multipolygon, by nesting depth.
|
||||
const depth = (value) => (Array.isArray(value) ? 1 + depth(value[0]) : 0);
|
||||
const d = depth(geometry);
|
||||
polygons = d === 2 ? [[geometry]] : d === 3 ? [geometry] : geometry;
|
||||
} else if (geometry?.type === 'Polygon') polygons = [geometry.coordinates];
|
||||
else if (geometry?.type === 'MultiPolygon') polygons = geometry.coordinates;
|
||||
else return null;
|
||||
const out = [];
|
||||
for (const polygon of Array.isArray(polygons) ? polygons : []) {
|
||||
if (!Array.isArray(polygon)) continue;
|
||||
const outer = cleanRing(polygon[0]);
|
||||
if (!outer) continue;
|
||||
const holes = polygon.slice(1).map(cleanRing).filter(Boolean);
|
||||
out.push([outer, ...holes]);
|
||||
}
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Longitudes made continuous across the antimeridian. Returns the ring as
|
||||
* given when it has no jump, or when its jumps do not cancel (a ring that
|
||||
* circles a pole).
|
||||
*/
|
||||
export function unwrapRing(ring) {
|
||||
let shift = 0;
|
||||
let jumped = false;
|
||||
const out = [[ring[0][0], ring[0][1]]];
|
||||
for (let i = 1; i < ring.length; i += 1) {
|
||||
const delta = ring[i][0] - ring[i - 1][0];
|
||||
if (delta > 180) {
|
||||
shift -= 360;
|
||||
jumped = true;
|
||||
} else if (delta < -180) {
|
||||
shift += 360;
|
||||
jumped = true;
|
||||
}
|
||||
out.push([ring[i][0] + shift, ring[i][1]]);
|
||||
}
|
||||
if (!jumped) return ring;
|
||||
return shift === 0 ? out : ring;
|
||||
}
|
||||
|
||||
function ringBbox(ring) {
|
||||
let w = Infinity;
|
||||
let s = Infinity;
|
||||
let e = -Infinity;
|
||||
let n = -Infinity;
|
||||
for (const [lon, lat] of ring) {
|
||||
if (lon < w) w = lon;
|
||||
if (lon > e) e = lon;
|
||||
if (lat < s) s = lat;
|
||||
if (lat > n) n = lat;
|
||||
}
|
||||
return [w, s, e, n];
|
||||
}
|
||||
|
||||
/** Even-odd ray cast. */
|
||||
function pointInRingXY(ring, lon, lat) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
const xi = ring[i][0];
|
||||
const yi = ring[i][1];
|
||||
const xj = ring[j][0];
|
||||
const yj = ring[j][1];
|
||||
if (
|
||||
yi > lat !== yj > lat &&
|
||||
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
|
||||
)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
const wrap180 = (lon) => ((((lon + 180) % 360) + 360) % 360) - 180;
|
||||
const wrap360 = (lon) => {
|
||||
const wrapped = lon % 360;
|
||||
return wrapped < 0 ? wrapped + 360 : wrapped;
|
||||
};
|
||||
|
||||
/** The shortest circular longitude interval containing every outer-ring vertex. */
|
||||
function longitudeEnvelope(parts) {
|
||||
const values = parts
|
||||
.flatMap(({ outer }) => outer.map(([lon]) => wrap360(lon)))
|
||||
.sort((a, b) => a - b);
|
||||
if (values.length === 0) return null;
|
||||
let gapIndex = values.length - 1;
|
||||
let largestGap = values[0] + 360 - values.at(-1);
|
||||
for (let i = 0; i < values.length - 1; i += 1) {
|
||||
const gap = values[i + 1] - values[i];
|
||||
if (gap > largestGap) {
|
||||
largestGap = gap;
|
||||
gapIndex = i;
|
||||
}
|
||||
}
|
||||
const start = values[(gapIndex + 1) % values.length];
|
||||
let end = values[gapIndex];
|
||||
if (end < start) end += 360;
|
||||
const span = end - start;
|
||||
if (span >= 360 - 1e-9) return { west: -180, east: 180, crosses: false };
|
||||
const west = start > 180 ? start - 360 : start;
|
||||
const endOnCircle = end >= 360 ? end - 360 : end;
|
||||
const east = endOnCircle > 180 ? endOnCircle - 360 : endOnCircle;
|
||||
return { west, east, crosses: west > east };
|
||||
}
|
||||
|
||||
/**
|
||||
* Index a MultiPolygon for repeated containment tests: unwrapped rings with
|
||||
* per-part bounding boxes, plus the overall bbox as [west, south, east, north]
|
||||
* in −180..180 (west > east when it crosses the antimeridian).
|
||||
* @param {Array} coordinates MultiPolygon coordinates.
|
||||
* @returns {{parts: Array, bbox: number[], vertexCount: number, crossesAntimeridian: boolean}|null}
|
||||
*/
|
||||
export function prepareArea(coordinates) {
|
||||
const multi = normalizeMultiPolygon(coordinates);
|
||||
if (!multi) return null;
|
||||
let vertexCount = 0;
|
||||
let crosses = false;
|
||||
const parts = multi.map((polygon) => {
|
||||
const outer = unwrapRing(polygon[0]);
|
||||
if (outer !== polygon[0]) crosses = true;
|
||||
const holes = polygon.slice(1).map((ring) => {
|
||||
let hole = unwrapRing(ring);
|
||||
// Keep a hole in the same longitude frame as its outer ring.
|
||||
const hb = ringBbox(hole);
|
||||
const ob = ringBbox(outer);
|
||||
const mid = (hb[0] + hb[2]) / 2;
|
||||
if (mid < ob[0]) hole = hole.map(([x, y]) => [x + 360, y]);
|
||||
else if (mid > ob[2]) hole = hole.map(([x, y]) => [x - 360, y]);
|
||||
return hole;
|
||||
});
|
||||
for (const ring of polygon) vertexCount += ring.length;
|
||||
return { outer, holes, bbox: ringBbox(outer) };
|
||||
});
|
||||
let south = Infinity;
|
||||
let north = -Infinity;
|
||||
for (const { bbox } of parts) {
|
||||
south = Math.min(south, bbox[1]);
|
||||
north = Math.max(north, bbox[3]);
|
||||
}
|
||||
const envelope = longitudeEnvelope(parts);
|
||||
const bbox = [envelope.west, south, envelope.east, north];
|
||||
crosses ||= envelope.crosses;
|
||||
return { parts, bbox, vertexCount, crossesAntimeridian: crosses };
|
||||
}
|
||||
|
||||
function inPart(part, lon, lat) {
|
||||
const [w, s, e, n] = part.bbox;
|
||||
if (lat < s || lat > n || lon < w || lon > e) return false;
|
||||
if (!pointInRingXY(part.outer, lon, lat)) return false;
|
||||
for (const hole of part.holes)
|
||||
if (pointInRingXY(hole, lon, lat)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a point lies inside the area: inside some part's outer ring and in
|
||||
* none of that part's holes. Points exactly on an edge follow the ray-cast
|
||||
* convention and may land either side.
|
||||
* @param {object} prepared From prepareArea.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function pointInPreparedArea(prepared, lat, lon) {
|
||||
if (!prepared || !Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
for (const part of prepared.parts) {
|
||||
if (inPart(part, lon, lat)) return true;
|
||||
if (inPart(part, lon + 360, lat)) return true;
|
||||
if (inPart(part, lon - 360, lat)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Spherical-excess ring area in km² (unsigned). */
|
||||
export function ringAreaKm2(ring) {
|
||||
const n = ring.length;
|
||||
if (n < 3) return 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n - 1; i += 1) {
|
||||
const [lon1, lat1] = ring[i];
|
||||
const [lon2, lat2] = ring[i + 1];
|
||||
sum +=
|
||||
toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
|
||||
}
|
||||
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Area of a MultiPolygon in km², holes subtracted.
|
||||
* @param {Array} coordinates
|
||||
* @returns {number}
|
||||
*/
|
||||
export function multiPolygonAreaKm2(coordinates) {
|
||||
const multi = normalizeMultiPolygon(coordinates);
|
||||
if (!multi) return 0;
|
||||
let total = 0;
|
||||
for (const polygon of multi) {
|
||||
total += ringAreaKm2(unwrapRing(polygon[0]));
|
||||
for (const hole of polygon.slice(1)) total -= ringAreaKm2(unwrapRing(hole));
|
||||
}
|
||||
return Math.max(0, total);
|
||||
}
|
||||
|
||||
/** Counts of parts and holes. */
|
||||
export function multiPolygonCounts(coordinates) {
|
||||
const multi = normalizeMultiPolygon(coordinates) || [];
|
||||
return {
|
||||
parts: multi.length,
|
||||
holes: multi.reduce((sum, polygon) => sum + polygon.length - 1, 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** Mean of the largest part's outer ring vertices, wrapped to −180..180. */
|
||||
export function areaAnchor(coordinates) {
|
||||
const multi = normalizeMultiPolygon(coordinates);
|
||||
if (!multi) return null;
|
||||
let best = null;
|
||||
let bestArea = -1;
|
||||
for (const polygon of multi) {
|
||||
const ring = unwrapRing(polygon[0]);
|
||||
const area = ringAreaKm2(ring);
|
||||
if (area > bestArea) {
|
||||
bestArea = area;
|
||||
best = ring;
|
||||
}
|
||||
}
|
||||
let lon = 0;
|
||||
let lat = 0;
|
||||
const pts = best.slice(0, -1);
|
||||
for (const [x, y] of pts) {
|
||||
lon += x;
|
||||
lat += y;
|
||||
}
|
||||
return { lon: wrap180(lon / pts.length), lat: lat / pts.length };
|
||||
}
|
||||
|
||||
// ── Display copies ────────────────────────────────────────────
|
||||
|
||||
function dpRing(ring, tol) {
|
||||
const n = ring.length;
|
||||
if (n <= 5) return ring;
|
||||
const keep = new Uint8Array(n);
|
||||
keep[0] = 1;
|
||||
keep[n - 1] = 1;
|
||||
const stack = [[0, n - 1]];
|
||||
while (stack.length) {
|
||||
const [a, b] = stack.pop();
|
||||
if (b - a < 2) continue;
|
||||
const [ax, ay] = ring[a];
|
||||
const vx = ring[b][0] - ax;
|
||||
const vy = ring[b][1] - ay;
|
||||
const c2 = vx * vx + vy * vy;
|
||||
let worst = -1;
|
||||
let worstD = tol;
|
||||
for (let i = a + 1; i < b; i += 1) {
|
||||
const wx = ring[i][0] - ax;
|
||||
const wy = ring[i][1] - ay;
|
||||
const t =
|
||||
c2 === 0 ? 0 : Math.max(0, Math.min(1, (vx * wx + vy * wy) / c2));
|
||||
const d = Math.hypot(wx - t * vx, wy - t * vy);
|
||||
if (d > worstD) {
|
||||
worstD = d;
|
||||
worst = i;
|
||||
}
|
||||
}
|
||||
if (worst >= 0) {
|
||||
keep[worst] = 1;
|
||||
stack.push([a, worst], [worst, b]);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i += 1) if (keep[i]) out.push(ring[i]);
|
||||
return out.length >= 4 ? out : ring;
|
||||
}
|
||||
|
||||
/**
|
||||
* A simplified copy of the area for drawing only: the largest `maxParts`
|
||||
* parts, each ring Douglas-Peucker simplified until the whole copy fits in
|
||||
* `maxVertices`. Rings keep their antimeridian-unwrapped longitudes so a
|
||||
* crossing part draws as one shape.
|
||||
* @param {Array} coordinates MultiPolygon coordinates.
|
||||
* @param {{maxVertices?: number, maxParts?: number}} [options]
|
||||
* @returns {{parts: Array<{outer: Array, holes: Array}>, drawnParts: number, totalParts: number}}
|
||||
*/
|
||||
export function displayParts(
|
||||
coordinates,
|
||||
{ maxVertices = 6000, maxParts = 40 } = {},
|
||||
) {
|
||||
const multi = normalizeMultiPolygon(coordinates) || [];
|
||||
const ranked = multi
|
||||
.map((polygon) => ({
|
||||
rings: polygon.map((ring) => unwrapRing(ring)),
|
||||
area: ringAreaKm2(unwrapRing(polygon[0])),
|
||||
}))
|
||||
.sort((a, b) => b.area - a.area)
|
||||
.slice(0, maxParts);
|
||||
const bbox = ranked.length ? ringBbox(ranked[0].rings[0]) : [0, 0, 0, 0];
|
||||
let tol = Math.max(
|
||||
1e-6,
|
||||
Math.max(bbox[2] - bbox[0], bbox[3] - bbox[1]) / 1500,
|
||||
);
|
||||
let parts = [];
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
parts = ranked.map(({ rings }) => ({
|
||||
outer: dpRing(rings[0], tol),
|
||||
holes: rings.slice(1).map((ring) => dpRing(ring, tol)),
|
||||
}));
|
||||
const total = parts.reduce(
|
||||
(sum, p) =>
|
||||
sum + p.outer.length + p.holes.reduce((h, r) => h + r.length, 0),
|
||||
0,
|
||||
);
|
||||
if (total <= maxVertices) break;
|
||||
tol *= 2;
|
||||
}
|
||||
return { parts, drawnParts: parts.length, totalParts: multi.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* A circle as a closed ring (planar metres around the centre — accurate for
|
||||
* the few-kilometre buffers it is used for).
|
||||
* @returns {Array<[number, number]>}
|
||||
*/
|
||||
export function circleRing(lat, lon, radiusM, segments = 48) {
|
||||
const mPerDegLat = 111_320;
|
||||
const mPerDegLon = mPerDegLat * Math.max(0.01, Math.cos(toRad(lat)));
|
||||
const ring = [];
|
||||
for (let i = 0; i < segments; i += 1) {
|
||||
const a = (i / segments) * Math.PI * 2;
|
||||
ring.push([
|
||||
lon + (Math.cos(a) * radiusM) / mPerDegLon,
|
||||
lat + (Math.sin(a) * radiusM) / mPerDegLat,
|
||||
]);
|
||||
}
|
||||
ring.push([ring[0][0], ring[0][1]]);
|
||||
return ring;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Area geometry: containment over full MultiPolygons (islands, holes, the
|
||||
// antimeridian), OSM relation assembly and display copies that never touch the
|
||||
// counting geometry.
|
||||
//
|
||||
// Run with: npm test
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
circleRing,
|
||||
displayParts,
|
||||
multiPolygonAreaKm2,
|
||||
multiPolygonCounts,
|
||||
normalizeMultiPolygon,
|
||||
pointInPreparedArea,
|
||||
prepareArea,
|
||||
} from './areaGeometry.js';
|
||||
|
||||
const square = (w, s, e, n) => [
|
||||
[w, s],
|
||||
[e, s],
|
||||
[e, n],
|
||||
[w, n],
|
||||
[w, s],
|
||||
];
|
||||
|
||||
test('a point on an island counts, not only on the largest part', () => {
|
||||
// Mainland 0..10, island 20..22: the old largest-ring shortcut dropped the island.
|
||||
const area = prepareArea([[square(0, 0, 10, 10)], [square(20, 0, 22, 2)]]);
|
||||
assert.equal(pointInPreparedArea(area, 5, 5), true);
|
||||
assert.equal(pointInPreparedArea(area, 1, 21), true);
|
||||
assert.equal(pointInPreparedArea(area, 1, 15), false);
|
||||
});
|
||||
|
||||
test('a point inside a hole is outside the area', () => {
|
||||
const area = prepareArea([[square(0, 0, 10, 10), square(4, 4, 6, 6)]]);
|
||||
assert.equal(pointInPreparedArea(area, 5, 5), false);
|
||||
assert.equal(pointInPreparedArea(area, 2, 2), true);
|
||||
// An island inside the hole (an enclave within an enclave) counts again.
|
||||
const nested = prepareArea([
|
||||
[square(0, 0, 10, 10), square(3, 3, 7, 7)],
|
||||
[square(4, 4, 6, 6)],
|
||||
]);
|
||||
assert.equal(pointInPreparedArea(nested, 5, 5), true);
|
||||
assert.equal(pointInPreparedArea(nested, 3.5, 3.5), false);
|
||||
});
|
||||
|
||||
test('a ring crossing the antimeridian contains points on both sides', () => {
|
||||
// 175E → 175W as one continuous ring (a drawn box over Fiji).
|
||||
const ring = [
|
||||
[175, -20],
|
||||
[-175, -20],
|
||||
[-175, -15],
|
||||
[175, -15],
|
||||
[175, -20],
|
||||
];
|
||||
const area = prepareArea([[ring]]);
|
||||
assert.equal(area.crossesAntimeridian, true);
|
||||
assert.equal(pointInPreparedArea(area, -17, 178), true);
|
||||
assert.equal(pointInPreparedArea(area, -17, -178), true);
|
||||
assert.equal(pointInPreparedArea(area, -17, 0), false);
|
||||
assert.equal(pointInPreparedArea(area, -17, 170), false);
|
||||
// bbox is west > east across the line.
|
||||
assert.deepEqual(area.bbox, [175, -20, -175, -15]);
|
||||
// Area of a 10°×5° box near 17°S, not the 350° long way round.
|
||||
const km2 = multiPolygonAreaKm2([[ring]]);
|
||||
assert.ok(km2 > 500_000 && km2 < 620_000, `area ${km2}`);
|
||||
});
|
||||
|
||||
test('a multipolygon split at ±180 keeps a tight bbox', () => {
|
||||
const area = prepareArea([
|
||||
[square(177, -19, 180, -16)],
|
||||
[square(-180, -19, -178, -16)],
|
||||
]);
|
||||
assert.equal(area.crossesAntimeridian, true);
|
||||
assert.deepEqual(area.bbox, [177, -19, -178, -16]);
|
||||
assert.equal(pointInPreparedArea(area, -17, -179), true);
|
||||
assert.equal(pointInPreparedArea(area, -17, 179), true);
|
||||
});
|
||||
|
||||
test('a crossing part and a separate island share one tight longitude frame', () => {
|
||||
const crossing = [
|
||||
[175, -20],
|
||||
[-175, -20],
|
||||
[-175, -15],
|
||||
[175, -15],
|
||||
[175, -20],
|
||||
];
|
||||
const area = prepareArea([[crossing], [square(-170, -19, -160, -16)]]);
|
||||
assert.equal(area.crossesAntimeridian, true);
|
||||
assert.deepEqual(area.bbox, [175, -20, -160, -15]);
|
||||
assert.equal(pointInPreparedArea(area, -17, 178), true);
|
||||
assert.equal(pointInPreparedArea(area, -17, -165), true);
|
||||
assert.equal(pointInPreparedArea(area, -17, 0), false);
|
||||
});
|
||||
|
||||
test('normalization closes rings and drops degenerate parts', () => {
|
||||
const multi = normalizeMultiPolygon({
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
],
|
||||
[
|
||||
[0.2, 0.2],
|
||||
[0.2, 0.2],
|
||||
],
|
||||
],
|
||||
});
|
||||
assert.equal(multi.length, 1);
|
||||
assert.equal(multi[0].length, 1, 'degenerate hole dropped');
|
||||
assert.deepEqual(multi[0][0][0], multi[0][0][3]);
|
||||
assert.equal(
|
||||
normalizeMultiPolygon({ type: 'LineString', coordinates: [] }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeMultiPolygon([
|
||||
[
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
]),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('area subtracts holes and counts parts', () => {
|
||||
const coords = [
|
||||
[square(0, 0, 1, 1), square(0.25, 0.25, 0.75, 0.75)],
|
||||
[square(5, 5, 6, 6)],
|
||||
];
|
||||
const whole = multiPolygonAreaKm2([[square(0, 0, 1, 1)]]);
|
||||
const km2 = multiPolygonAreaKm2(coords);
|
||||
assert.ok(km2 < whole * 2 && km2 > whole * 1.7, `${km2} vs ${whole}`);
|
||||
assert.deepEqual(multiPolygonCounts(coords), { parts: 2, holes: 1 });
|
||||
});
|
||||
|
||||
test('display copies are bounded and leave the counting geometry intact', () => {
|
||||
const dense = [];
|
||||
for (let i = 0; i < 20_000; i += 1) {
|
||||
const a = (i / 20_000) * Math.PI * 2;
|
||||
dense.push([
|
||||
Math.cos(a) * (1 + 0.01 * Math.sin(a * 200)),
|
||||
Math.sin(a) * (1 + 0.01 * Math.sin(a * 200)),
|
||||
]);
|
||||
}
|
||||
dense.push(dense[0].slice());
|
||||
const coords = [[dense]];
|
||||
const snapshot = JSON.stringify(coords);
|
||||
const shown = displayParts(coords, { maxVertices: 2000 });
|
||||
const total = shown.parts.reduce((sum, p) => sum + p.outer.length, 0);
|
||||
assert.ok(total <= 2000, `${total} vertices`);
|
||||
assert.equal(JSON.stringify(coords), snapshot, 'input untouched');
|
||||
// A point the simplified copy might shave off still counts on the original.
|
||||
const area = prepareArea(coords);
|
||||
assert.equal(pointInPreparedArea(area, 0, 0), true);
|
||||
});
|
||||
|
||||
test('display copies keep the largest parts first', () => {
|
||||
const coords = [
|
||||
[square(0, 0, 1, 1)],
|
||||
[square(10, 10, 15, 15)],
|
||||
[square(20, 20, 20.1, 20.1)],
|
||||
];
|
||||
const shown = displayParts(coords, { maxParts: 2 });
|
||||
assert.equal(shown.totalParts, 3);
|
||||
assert.equal(shown.drawnParts, 2);
|
||||
assert.equal(shown.parts[0].outer[0][0], 10);
|
||||
});
|
||||
|
||||
test('a circle ring is closed and about the requested radius', () => {
|
||||
const ring = circleRing(37.8, -122.39, 500);
|
||||
assert.deepEqual(ring[0], ring[ring.length - 1]);
|
||||
const km2 = multiPolygonAreaKm2([[ring]]);
|
||||
assert.ok(Math.abs(km2 - Math.PI * 0.25) < 0.02, `${km2}`);
|
||||
});
|
||||
@@ -0,0 +1,668 @@
|
||||
/**
|
||||
* Named place → area handle, through the outline ladder that needs no public
|
||||
* Overpass:
|
||||
*
|
||||
* 1. the area store (a query already resolved this session);
|
||||
* 2. bundled Natural Earth physical/marine regions;
|
||||
* 3. bundled countries and states/provinces by name — a name several
|
||||
* prominent units share ("Georgia", "Punjab") asks which one, unless
|
||||
* the view is inside exactly one of them;
|
||||
* 4. the annotation outline ladder (`resolveNamedOutline`): bundled
|
||||
* counties, US Census places and neighbourhood packs, then the guarded
|
||||
* Nominatim outline route, then Overpass only when an operator
|
||||
* configured it.
|
||||
*
|
||||
* Providers are injected, so the ladder runs under node:test:
|
||||
* findNaturalRegion(query) → {name, polygons, kind}|null
|
||||
* findAdminCandidates(query) → Array<AdminArea> (see data/adminBoundaries.js)
|
||||
* resolveNamedOutline(query, {level, levelHint, signal}) →
|
||||
* {ok:true, name, polygons, source, rung, approximate?, country?}
|
||||
* |{ok:false, code, error}
|
||||
* viewCenter() → {lat, lon}|null
|
||||
*
|
||||
* @module data/areaResolver
|
||||
*/
|
||||
|
||||
import { AREA_LEVELS } from './areaStore.js';
|
||||
import { pointInPreparedArea, prepareArea } from './areaGeometry.js';
|
||||
import { adminQueryIdentity, normalizeAdminName } from './adminBoundaries.js';
|
||||
import {
|
||||
isAreaCandidateId,
|
||||
MAX_AREA_CANDIDATE_ID_LENGTH,
|
||||
} from '../voice/areaCandidateContract.js';
|
||||
export {
|
||||
AREA_CANDIDATE_ID_PATTERN,
|
||||
isAreaCandidateId,
|
||||
MAX_AREA_CANDIDATE_ID_LENGTH,
|
||||
} from '../voice/areaCandidateContract.js';
|
||||
|
||||
/** Most candidates a clarification offers. */
|
||||
export const MAX_AREA_CANDIDATES = 4;
|
||||
/** Candidates remembered for a follow-up `candidateId`. */
|
||||
const CANDIDATE_MEMORY = 24;
|
||||
|
||||
/**
|
||||
* A place name as a cache and matching key: case-folded and NFKC-normalized,
|
||||
* punctuation dropped, letters, marks and digits in every script kept (東京
|
||||
* and 大阪 are different keys). Empty when the text names nothing.
|
||||
*/
|
||||
export function normalizeAreaQuery(text) {
|
||||
return String(text || '')
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{M}\p{N} ]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^the /, '');
|
||||
}
|
||||
|
||||
/** Words that name the kind of area rather than the area. */
|
||||
const LEVEL_WORDS = [
|
||||
[/\b(province|state|region|oblast|prefecture|canton|governorate)$/, 'admin1'],
|
||||
[/\b(county|district|department)$/, 'admin2'],
|
||||
[/\b(city|town|municipality|metropolitan city)$/, 'city'],
|
||||
[/\b(neighbou?rhood|borough|suburb|quarter)$/, 'district'],
|
||||
[/\b(country|nation)$/, 'country'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Strip a trailing level word ("Bagmati province" → "bagmati", admin1). The
|
||||
* word is kept as a hint, not a filter, since names often contain it.
|
||||
*/
|
||||
export function splitLevelWord(query) {
|
||||
const norm = normalizeAreaQuery(query);
|
||||
for (const [re, level] of LEVEL_WORDS) {
|
||||
if (re.test(norm)) {
|
||||
const bare = norm.replace(re, '').trim();
|
||||
if (bare) return { bare, level };
|
||||
}
|
||||
}
|
||||
return { bare: norm, level: null };
|
||||
}
|
||||
|
||||
/** Area level of a bundled admin unit. */
|
||||
const ADMIN_LEVEL = Object.freeze({
|
||||
country: 'country',
|
||||
state: 'admin1',
|
||||
county: 'admin2',
|
||||
natural: 'natural',
|
||||
});
|
||||
|
||||
/**
|
||||
* One of several same-name natural places as a clarification choice, located
|
||||
* by its rough centre ("39 N 31 W") since it has no country.
|
||||
*/
|
||||
function naturalCandidate(place, kind) {
|
||||
const { lat, lon } = place.center || {};
|
||||
const locator = Number.isFinite(lat)
|
||||
? `${Math.abs(Math.round(lat))} ${lat >= 0 ? 'N' : 'S'} ${Math.abs(Math.round(lon))} ${lon >= 0 ? 'E' : 'W'}`
|
||||
: '';
|
||||
return {
|
||||
kind: 'natural',
|
||||
name: place.name,
|
||||
country: '',
|
||||
regionCode: locator,
|
||||
polygons: place.polygons.map((ring) => [ring]),
|
||||
source: 'natural-earth',
|
||||
meta: { kind },
|
||||
};
|
||||
}
|
||||
|
||||
const ADMIN_NAME_LEVEL = Object.freeze({
|
||||
country: 'country',
|
||||
nation: 'country',
|
||||
province: 'admin1',
|
||||
state: 'admin1',
|
||||
region: 'admin1',
|
||||
oblast: 'admin1',
|
||||
prefecture: 'admin1',
|
||||
canton: 'admin1',
|
||||
governorate: 'admin1',
|
||||
county: 'admin2',
|
||||
district: 'admin2',
|
||||
department: 'admin2',
|
||||
});
|
||||
|
||||
/**
|
||||
* Drop accents from Latin letters only, for comparing names: speech often
|
||||
* loses them ("Quebec" for "Québec"), while a Thai or Devanagari mark changes
|
||||
* the name and is kept.
|
||||
*/
|
||||
export function foldLatinMarks(text) {
|
||||
return String(text || '')
|
||||
.normalize('NFD')
|
||||
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
|
||||
.normalize('NFC');
|
||||
}
|
||||
|
||||
function explicitAdminName(text) {
|
||||
const normalized = normalizeAreaQuery(String(text).split(',')[0]);
|
||||
const match = normalized.match(
|
||||
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/,
|
||||
);
|
||||
return {
|
||||
normalized,
|
||||
bare: foldLatinMarks(
|
||||
match ? normalized.slice(0, -match[0].length).trim() : normalized,
|
||||
),
|
||||
word: match?.[1] || null,
|
||||
level: match ? ADMIN_NAME_LEVEL[match[1]] || null : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail closed when a bundled unit's own name contradicts the requested unit.
|
||||
* With `aliases`, an ask the administrative matcher reads as naming this very
|
||||
* unit at its level ("State of Texas", "USA") also matches; the natural-region
|
||||
* check leaves it off, since "Great Britain" is not the United Kingdom.
|
||||
*/
|
||||
function bundledAdminIdentityRefusal(
|
||||
admin,
|
||||
query,
|
||||
wantedLevel,
|
||||
{ aliases = true } = {},
|
||||
) {
|
||||
const source = explicitAdminName(admin?.name || '');
|
||||
const requested = explicitAdminName(query);
|
||||
const expectedLevel = wantedLevel || requested.level;
|
||||
const emittedLevel = ADMIN_LEVEL[admin?.kind] || null;
|
||||
if (!source.normalized || source.word === 'zone')
|
||||
return {
|
||||
ok: false,
|
||||
code: 'AREA_IDENTITY_UNVERIFIED',
|
||||
error: `The bundled boundary is ${admin?.name || 'an unverified administrative unit'}, not a verified current match for "${query}".`,
|
||||
};
|
||||
const alias = aliases ? adminQueryIdentity(query) : null;
|
||||
const named =
|
||||
source.bare === requested.bare ||
|
||||
Boolean(
|
||||
alias &&
|
||||
alias.level === emittedLevel &&
|
||||
alias.name === normalizeAdminName(admin.name),
|
||||
);
|
||||
if (
|
||||
!named ||
|
||||
(source.level && expectedLevel && source.level !== expectedLevel) ||
|
||||
(expectedLevel && emittedLevel !== expectedLevel)
|
||||
)
|
||||
return {
|
||||
ok: false,
|
||||
code: 'AREA_IDENTITY_MISMATCH',
|
||||
error: `The bundled boundary is ${admin.name}, not the requested administrative area "${query}".`,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
function stableCandidateHash(value) {
|
||||
let hash = 0x811c9dc5;
|
||||
for (const char of String(value)) {
|
||||
hash ^= char.codePointAt(0);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
function candidateIdOf(admin) {
|
||||
const prefix = `ne:${admin.kind}:`;
|
||||
// The region code keeps same-named units apart: "Washington" is both WA
|
||||
// and DC, which would otherwise share one candidate and one stored area.
|
||||
const slug = normalizeAreaQuery(
|
||||
`${admin.country} ${admin.name} ${admin.regionCode || ''}`,
|
||||
).replace(/ /g, '-');
|
||||
const full = `${prefix}${slug}`;
|
||||
if (full.length <= MAX_AREA_CANDIDATE_ID_LENGTH) return full;
|
||||
const suffix = `-${stableCandidateHash(slug)}`;
|
||||
let shortened = '';
|
||||
for (const char of slug) {
|
||||
if (
|
||||
`${prefix}${shortened}${char}${suffix}`.length >
|
||||
MAX_AREA_CANDIDATE_ID_LENGTH
|
||||
)
|
||||
break;
|
||||
shortened += char;
|
||||
}
|
||||
return `${prefix}${shortened.replace(/-+$/u, '')}${suffix}`;
|
||||
}
|
||||
|
||||
/** Short, speakable identity for a clarification choice. */
|
||||
function describeCandidate(admin) {
|
||||
return {
|
||||
candidateId: candidateIdOf(admin),
|
||||
name: admin.name,
|
||||
level: ADMIN_LEVEL[admin.kind] || null,
|
||||
// Tells same-named units apart when spoken: Washington WA vs DC.
|
||||
...(admin.regionCode && admin.kind !== 'country'
|
||||
? { region: String(admin.regionCode) }
|
||||
: {}),
|
||||
...(admin.country && admin.kind !== 'country'
|
||||
? { country: admin.country }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct places among bundled candidates. Within one country, the country
|
||||
* itself stands for its same-named state ("Mexico" is the country, not the
|
||||
* State of Mexico); across countries each name is its own place.
|
||||
*/
|
||||
/** A coarse ~2° cell of the view, or "none", for keying view-picked answers. */
|
||||
function viewCell(center) {
|
||||
if (!Number.isFinite(center?.lat) || !Number.isFinite(center?.lon))
|
||||
return 'none';
|
||||
return `${Math.floor(center.lat / 2)},${Math.floor(center.lon / 2)}`;
|
||||
}
|
||||
|
||||
export function distinctAdminPlaces(candidates) {
|
||||
const byCountry = new Map();
|
||||
for (const admin of candidates) {
|
||||
const list = byCountry.get(admin.country) || [];
|
||||
list.push(admin);
|
||||
byCountry.set(admin.country, list);
|
||||
}
|
||||
const out = [];
|
||||
for (const list of byCountry.values()) {
|
||||
const country = list.find((admin) => admin.kind === 'country');
|
||||
out.push(...(country ? [country] : list));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a resolver bound to an area store and providers.
|
||||
* @param {object} options
|
||||
*/
|
||||
export function createAreaResolver({
|
||||
store,
|
||||
findNaturalRegion = async () => null,
|
||||
findAdminCandidates = async () => [],
|
||||
resolveNamedOutline = async () => ({
|
||||
ok: false,
|
||||
code: 'AREA_UNAVAILABLE',
|
||||
error: 'Boundary lookup is unavailable here.',
|
||||
}),
|
||||
viewCenter = () => null,
|
||||
onCredit = () => {},
|
||||
now = () => Date.now(),
|
||||
maxInFlight = 8,
|
||||
} = {}) {
|
||||
if (!store) throw new TypeError('An area store is required');
|
||||
/** candidateId → bundled unit and original ask, for a clarification answered later. */
|
||||
const candidateMemory = new Map();
|
||||
/** query key → in-flight lookup, so a repeat joins the first. */
|
||||
const inFlight = new Map();
|
||||
const lifetime = new AbortController();
|
||||
const inFlightLimit = Number.isFinite(maxInFlight)
|
||||
? Math.max(1, Math.floor(maxInFlight))
|
||||
: 8;
|
||||
let activeLookups = 0;
|
||||
let disposed = false;
|
||||
|
||||
const assertActive = () => lifetime.signal.throwIfAborted();
|
||||
|
||||
const remember = (admin, identity) => {
|
||||
const id = candidateIdOf(admin);
|
||||
candidateMemory.delete(id);
|
||||
candidateMemory.set(id, { admin, identity });
|
||||
while (candidateMemory.size > CANDIDATE_MEMORY)
|
||||
candidateMemory.delete(candidateMemory.keys().next().value);
|
||||
};
|
||||
|
||||
function storeAdmin(admin, confidence, identity) {
|
||||
assertActive();
|
||||
const refused = bundledAdminIdentityRefusal(
|
||||
admin,
|
||||
identity?.query,
|
||||
identity?.wantedLevel,
|
||||
);
|
||||
if (refused) return refused;
|
||||
const record = store.put({
|
||||
geometry: admin.polygons,
|
||||
name: admin.name,
|
||||
level: ADMIN_LEVEL[admin.kind] || null,
|
||||
source: admin.source,
|
||||
sourceId: candidateIdOf(admin),
|
||||
meta: {
|
||||
...(admin.kind !== 'country' && admin.country
|
||||
? { country: admin.country }
|
||||
: {}),
|
||||
rung: 'bundled',
|
||||
},
|
||||
});
|
||||
if (!record)
|
||||
return {
|
||||
ok: false,
|
||||
code: 'AREA_NOT_FOUND',
|
||||
error: `The boundary for ${admin.name} is not a usable polygon.`,
|
||||
};
|
||||
onCredit(admin.source);
|
||||
return { ok: true, record, confidence, rung: 'bundled' };
|
||||
}
|
||||
|
||||
async function lookup({ query, level, within }, { signal }) {
|
||||
const { level: levelHint } = splitLevelWord(query);
|
||||
const searchText = within ? `${query}, ${within}` : query;
|
||||
let bundledRefusal = null;
|
||||
// Offline first: a named natural region, unless an admin level or a
|
||||
// country/state qualifier was given (the pack cannot check `within`).
|
||||
if ((!level || level === 'natural') && !within) {
|
||||
const ne = await findNaturalRegion(query, { signal }).catch(() => null);
|
||||
signal?.throwIfAborted?.();
|
||||
// A region named like a country or state ("India" the peninsula,
|
||||
// "Punjab" the desert) never hides it; asked for by level, it answers.
|
||||
// Only a unit that really has this name counts: "Great Britain" finds
|
||||
// the United Kingdom by alias, which is not a Great Britain.
|
||||
// A feature split into parts is one place with every part; distant
|
||||
// namesakes (Flores in the Azores and Indonesia) are asked about.
|
||||
const places = ne?.places?.length
|
||||
? ne.places
|
||||
: ne?.polygons?.length
|
||||
? [{ name: ne.name, polygons: ne.polygons }]
|
||||
: [];
|
||||
const shadowsAdmin =
|
||||
places.length > 0 &&
|
||||
level !== 'natural' &&
|
||||
(await findAdminCandidates(query, { signal }).catch(() => [])).some(
|
||||
(admin) =>
|
||||
!bundledAdminIdentityRefusal(admin, query, null, {
|
||||
aliases: false,
|
||||
}),
|
||||
);
|
||||
signal?.throwIfAborted?.();
|
||||
if (places.length > 1 && !shadowsAdmin) {
|
||||
const offered = places
|
||||
.slice(0, MAX_AREA_CANDIDATES)
|
||||
.map((place) => naturalCandidate(place, ne.kind));
|
||||
const identity = { query, wantedLevel: 'natural' };
|
||||
offered.forEach((admin) => remember(admin, identity));
|
||||
return {
|
||||
ok: false,
|
||||
needsClarification: true,
|
||||
code: 'AMBIGUOUS_AREA',
|
||||
candidates: offered.map(describeCandidate),
|
||||
error: `"${query}" names ${offered.length} places — ask which one.`,
|
||||
};
|
||||
}
|
||||
if (places.length === 1 && !shadowsAdmin) {
|
||||
const record = store.put({
|
||||
geometry: places[0].polygons.map((ring) => [ring]),
|
||||
name: ne.name,
|
||||
level: 'natural',
|
||||
source: 'natural-earth',
|
||||
sourceId: `ne:${normalizeAreaQuery(ne.name).replace(/ /g, '-')}`,
|
||||
meta: { kind: ne.kind, rung: 'bundled' },
|
||||
});
|
||||
if (record) {
|
||||
onCredit('natural-earth');
|
||||
return { ok: true, record, confidence: 0.95, rung: 'bundled' };
|
||||
}
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted?.();
|
||||
// Bundled countries and states by name. A name prominent units share is
|
||||
// asked about, never guessed — except when the view is inside one.
|
||||
const wanted = level || levelHint;
|
||||
if (!wanted || wanted === 'country' || wanted === 'admin1') {
|
||||
let admins = await findAdminCandidates(searchText, { signal }).catch(
|
||||
() => [],
|
||||
);
|
||||
signal?.throwIfAborted?.();
|
||||
if (wanted) admins = admins.filter((a) => ADMIN_LEVEL[a.kind] === wanted);
|
||||
let places = distinctAdminPlaces(admins);
|
||||
const identity = { query, wantedLevel: wanted };
|
||||
const checked = places.map((admin) => ({
|
||||
admin,
|
||||
refused: bundledAdminIdentityRefusal(admin, query, wanted),
|
||||
}));
|
||||
places = checked
|
||||
.filter(({ refused }) => !refused)
|
||||
.map(({ admin }) => admin);
|
||||
if (!places.length && checked.length) {
|
||||
const refused = checked.find((entry) => entry.refused)?.refused;
|
||||
// A retired or unverifiable unit is final. A different unit that
|
||||
// merely shares the name ("Azad Kashmir" for "Kashmir") is not an
|
||||
// answer, so the outline ladder still gets to look.
|
||||
if (refused?.code !== 'AREA_IDENTITY_MISMATCH') return refused;
|
||||
bundledRefusal = refused;
|
||||
}
|
||||
if (places.length === 1) {
|
||||
return storeAdmin(places[0], 0.95, identity);
|
||||
}
|
||||
if (places.length > 1) {
|
||||
const center = viewCenter();
|
||||
const holding = center
|
||||
? places.filter((admin) => {
|
||||
const prepared = prepareArea(admin.polygons);
|
||||
return (
|
||||
prepared &&
|
||||
pointInPreparedArea(prepared, center.lat, center.lon)
|
||||
);
|
||||
})
|
||||
: [];
|
||||
// Chosen by the view, so never remembered for the name: asked again
|
||||
// over the other one, it answers the other one.
|
||||
if (holding.length === 1)
|
||||
return { ...storeAdmin(holding[0], 0.85, identity), byView: true };
|
||||
const offered = places.slice(0, MAX_AREA_CANDIDATES);
|
||||
offered.forEach((admin) => remember(admin, identity));
|
||||
return {
|
||||
ok: false,
|
||||
needsClarification: true,
|
||||
code: 'AMBIGUOUS_AREA',
|
||||
candidates: offered.map(describeCandidate),
|
||||
error: `"${query}" names ${offered.length} places — ask which one.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted?.();
|
||||
// Everything else: the annotation outline ladder.
|
||||
const outline = await resolveNamedOutline(searchText, {
|
||||
level,
|
||||
levelHint,
|
||||
signal,
|
||||
});
|
||||
signal?.throwIfAborted?.();
|
||||
if (!outline?.ok)
|
||||
return (
|
||||
bundledRefusal || {
|
||||
ok: false,
|
||||
code: outline?.code || 'AREA_NOT_FOUND',
|
||||
error: outline?.error || `No boundary found for "${query}".`,
|
||||
}
|
||||
);
|
||||
const record = store.put({
|
||||
geometry: outline.polygons,
|
||||
name: outline.name || query,
|
||||
level: level || levelHint || outline.adminLevel || null,
|
||||
source: outline.approximate ? 'approximate' : outline.source,
|
||||
approximate: Boolean(outline.approximate),
|
||||
meta: {
|
||||
...(outline.country ? { country: outline.country } : {}),
|
||||
...(outline.adminName ? { adminName: outline.adminName } : {}),
|
||||
...(outline.adminLevel ? { adminLevel: outline.adminLevel } : {}),
|
||||
rung: outline.rung || null,
|
||||
...(outline.basis ? { basis: outline.basis } : {}),
|
||||
},
|
||||
});
|
||||
if (!record)
|
||||
return {
|
||||
ok: false,
|
||||
code: 'AREA_NOT_FOUND',
|
||||
error: `The boundary for ${query} is not a usable polygon.`,
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
record,
|
||||
confidence: outline.approximate ? 0.5 : 0.8,
|
||||
rung: outline.rung || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a place to an area handle.
|
||||
* @param {{query?: string, level?: string, within?: string, candidateId?: string}} args
|
||||
* @param {{signal?: AbortSignal, budgetMs?: number}} [options] Past the
|
||||
* budget the call answers AREA_TIMEOUT while the lookup continues and
|
||||
* fills the caches, so asking again shortly is fast.
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function resolve(args = {}, { signal, budgetMs = 8000 } = {}) {
|
||||
const started = now();
|
||||
const level = AREA_LEVELS.includes(args.level) ? args.level : null;
|
||||
const within = String(args.within || '').trim();
|
||||
const candidateId = String(args.candidateId || '').trim();
|
||||
const finish = (result, cached = false) => ({
|
||||
...result,
|
||||
cached,
|
||||
ms: now() - started,
|
||||
});
|
||||
|
||||
if (disposed)
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'CANCELLED',
|
||||
cancelled: true,
|
||||
error: 'Area resolver has been disposed.',
|
||||
});
|
||||
|
||||
if (candidateId) {
|
||||
if (!isAreaCandidateId(candidateId))
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'BAD_CANDIDATE',
|
||||
error:
|
||||
'candidateId is malformed or too long. Resolve the name again.',
|
||||
});
|
||||
const remembered = candidateMemory.get(candidateId);
|
||||
if (!remembered)
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'UNKNOWN_CANDIDATE',
|
||||
error: `Unknown candidateId "${candidateId}". Resolve the name again.`,
|
||||
});
|
||||
if (signal?.aborted)
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'CANCELLED',
|
||||
cancelled: true,
|
||||
error: 'This request was superseded.',
|
||||
});
|
||||
return finish(storeAdmin(remembered.admin, 0.9, remembered.identity));
|
||||
}
|
||||
|
||||
const query = String(args.query || '').trim();
|
||||
if (!query)
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'BAD_AREA',
|
||||
error: 'Name a place to resolve.',
|
||||
});
|
||||
const nameKey = normalizeAreaQuery(query);
|
||||
// The outline ladder and geocoder pick by the view ("Springfield" over
|
||||
// Illinois), so an answer is reused or shared only within the same
|
||||
// ~2° view cell. A name that normalizes to nothing is never cached.
|
||||
const key = nameKey
|
||||
? `${nameKey}|${level || ''}|${normalizeAreaQuery(within)}|${viewCell(viewCenter())}`
|
||||
: null;
|
||||
const recalled = key ? store.recallQuery(key) : null;
|
||||
if (recalled)
|
||||
return finish(
|
||||
{ ok: true, record: recalled, confidence: 0.95, rung: 'store' },
|
||||
true,
|
||||
);
|
||||
|
||||
let pending = key ? inFlight.get(key) : null;
|
||||
if (!pending) {
|
||||
if (activeLookups >= inFlightLimit)
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'AREA_BUSY',
|
||||
error:
|
||||
'Too many area lookups are already running — try again shortly.',
|
||||
});
|
||||
// The shared lookup outlives any one caller's signal: a superseded turn
|
||||
// must not cancel the answer a repeat is waiting on. The resolver's own
|
||||
// lifetime still owns the provider work, so final teardown stops it.
|
||||
activeLookups += 1;
|
||||
pending = lookup({ query, level, within }, { signal: lifetime.signal })
|
||||
.then((result) => {
|
||||
assertActive();
|
||||
if (result.ok && key && !result.byView)
|
||||
store.rememberQuery(key, result.record.areaId);
|
||||
return result;
|
||||
})
|
||||
.catch((error) =>
|
||||
lifetime.signal.aborted
|
||||
? {
|
||||
ok: false,
|
||||
code: 'CANCELLED',
|
||||
cancelled: true,
|
||||
error: 'Area resolver has been disposed.',
|
||||
}
|
||||
: {
|
||||
ok: false,
|
||||
code: 'AREA_UNAVAILABLE',
|
||||
error: error?.message || 'Boundary lookup failed.',
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
activeLookups -= 1;
|
||||
if (key) inFlight.delete(key);
|
||||
});
|
||||
if (key) inFlight.set(key, pending);
|
||||
}
|
||||
let timer;
|
||||
const racers = [pending];
|
||||
if (Number.isFinite(budgetMs))
|
||||
racers.push(
|
||||
new Promise((resolveTimeout) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
resolveTimeout({
|
||||
ok: false,
|
||||
code: 'AREA_TIMEOUT',
|
||||
error: `Looking up "${query}" is taking a while — ask again in a moment.`,
|
||||
}),
|
||||
budgetMs,
|
||||
);
|
||||
}),
|
||||
);
|
||||
// Detached once this call settles: a caller's long-lived signal must not
|
||||
// collect one listener per completed lookup.
|
||||
let cancelled = null;
|
||||
if (signal)
|
||||
racers.push(
|
||||
new Promise((resolveAbort) => {
|
||||
const superseded = {
|
||||
ok: false,
|
||||
code: 'CANCELLED',
|
||||
cancelled: true,
|
||||
error: 'Superseded.',
|
||||
};
|
||||
if (signal.aborted) return resolveAbort(superseded);
|
||||
cancelled = () => resolveAbort(superseded);
|
||||
signal.addEventListener?.('abort', cancelled, { once: true });
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
try {
|
||||
return finish(await Promise.race(racers));
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (cancelled) signal.removeEventListener?.('abort', cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
lifetime.abort();
|
||||
inFlight.clear();
|
||||
candidateMemory.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
resolve,
|
||||
dispose,
|
||||
candidate: (id) => candidateMemory.get(id) || null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,896 @@
|
||||
// Area handles: the session store and the resolution ladder (bundled Natural
|
||||
// Earth regions → bundled countries and states → the annotation outline
|
||||
// ladder), with every rung mocked. Pins the clarification contract for
|
||||
// ambiguous names and the fast cached path.
|
||||
//
|
||||
// Run with: npm test
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAreaStore, summarizeArea } from './areaStore.js';
|
||||
import {
|
||||
AREA_CANDIDATE_ID_PATTERN,
|
||||
createAreaResolver,
|
||||
distinctAdminPlaces,
|
||||
isAreaCandidateId,
|
||||
MAX_AREA_CANDIDATE_ID_LENGTH,
|
||||
splitLevelWord,
|
||||
} from './areaResolver.js';
|
||||
import { pointInPreparedArea } from './areaGeometry.js';
|
||||
import { findAdminCandidates } from './adminBoundaries.js';
|
||||
|
||||
const square = (w, s, e, n) => [
|
||||
[w, s],
|
||||
[e, s],
|
||||
[e, n],
|
||||
[w, n],
|
||||
[w, s],
|
||||
];
|
||||
|
||||
const admin = (kind, name, country, box, extra = {}) => ({
|
||||
kind,
|
||||
name,
|
||||
country,
|
||||
source: 'natural-earth',
|
||||
polygons: [[square(...box)]],
|
||||
...extra,
|
||||
});
|
||||
const GEORGIA_COUNTRY = admin(
|
||||
'country',
|
||||
'Georgia',
|
||||
'Georgia',
|
||||
[40, 41, 46.7, 43.6],
|
||||
);
|
||||
const GEORGIA_STATE = admin(
|
||||
'state',
|
||||
'Georgia',
|
||||
'United States of America',
|
||||
[-85.6, 30.4, -80.8, 35],
|
||||
);
|
||||
const PUNJAB_IN = admin('state', 'Punjab', 'India', [73.8, 29.5, 77, 32.5]);
|
||||
const PUNJAB_PK = admin('state', 'Punjab', 'Pakistan', [69.3, 27.7, 73.7, 34]);
|
||||
const TEXAS = admin(
|
||||
'state',
|
||||
'Texas',
|
||||
'United States of America',
|
||||
[-106, 26, -94, 36],
|
||||
);
|
||||
|
||||
const BAGMATI = {
|
||||
ok: true,
|
||||
name: 'Bagmati Province',
|
||||
polygons: [[square(84.4, 26.9, 86.6, 28.4), square(85.3, 27.6, 85.4, 27.7)]],
|
||||
source: 'osm',
|
||||
rung: 'nominatim',
|
||||
adminName: 'Bagmati Province',
|
||||
};
|
||||
|
||||
function makeResolver(overrides = {}) {
|
||||
const store = createAreaStore();
|
||||
const calls = { outline: [], natural: 0, admin: 0 };
|
||||
const resolver = createAreaResolver({
|
||||
store,
|
||||
findNaturalRegion: async (q) => {
|
||||
calls.natural += 1;
|
||||
return /alps/i.test(q)
|
||||
? {
|
||||
name: 'Alps',
|
||||
kind: 'natural',
|
||||
polygons: [square(5, 44, 16, 48), square(17, 44, 18, 45)],
|
||||
}
|
||||
: null;
|
||||
},
|
||||
findAdminCandidates: async (q) => {
|
||||
calls.admin += 1;
|
||||
if (/^georgia$/i.test(q)) return [GEORGIA_STATE, GEORGIA_COUNTRY];
|
||||
if (/^georgia, united states/i.test(q)) return [GEORGIA_STATE];
|
||||
if (/^punjab$/i.test(q)) return [PUNJAB_PK, PUNJAB_IN];
|
||||
if (/^punjab, pakistan/i.test(q)) return [PUNJAB_PK];
|
||||
if (/^texas$/i.test(q)) return [TEXAS];
|
||||
return [];
|
||||
},
|
||||
resolveNamedOutline: async (q, options) => {
|
||||
calls.outline.push({ q, ...options, signal: undefined });
|
||||
if (/bagmati/i.test(q)) return BAGMATI;
|
||||
return {
|
||||
ok: false,
|
||||
code: 'AREA_NOT_FOUND',
|
||||
error: `No boundary found for "${q}".`,
|
||||
};
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
return { store, resolver, calls };
|
||||
}
|
||||
|
||||
test('the store keeps geometry local and summarizes without coordinates', () => {
|
||||
const store = createAreaStore({ maxEntries: 2 });
|
||||
const a = store.put({
|
||||
geometry: [[square(0, 0, 1, 1)]],
|
||||
name: 'A',
|
||||
source: 'drawn',
|
||||
level: 'drawn',
|
||||
});
|
||||
const b = store.put({
|
||||
geometry: [[square(2, 2, 3, 3)]],
|
||||
name: 'B',
|
||||
source: 'us-census',
|
||||
sourceId: 'place:1',
|
||||
});
|
||||
assert.equal(b.areaId, 'place:1');
|
||||
assert.equal(b.source, 'us-census');
|
||||
store.get(a.areaId); // A is now the most recently used
|
||||
store.put({ geometry: [[square(4, 4, 5, 5)]], name: 'C', source: 'osm' });
|
||||
assert.equal(store.has('place:1'), false, 'least recently used evicted');
|
||||
assert.ok(store.get(a.areaId));
|
||||
const summary = summarizeArea(a);
|
||||
assert.deepEqual(
|
||||
Object.keys(summary).sort(),
|
||||
[
|
||||
'areaId',
|
||||
'areaKm2',
|
||||
'bbox',
|
||||
'holes',
|
||||
'level',
|
||||
'name',
|
||||
'parts',
|
||||
'source',
|
||||
].sort(),
|
||||
);
|
||||
assert.equal(JSON.stringify(summary).includes('geometry'), false);
|
||||
assert.equal(
|
||||
store.put({
|
||||
geometry: [
|
||||
[
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
],
|
||||
name: 'bad',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('query aliases have their own LRU bound even for one stable area', () => {
|
||||
const store = createAreaStore({ maxEntries: 2, maxQueryEntries: 3 });
|
||||
const area = store.put({
|
||||
geometry: [[square(0, 0, 1, 1)]],
|
||||
name: 'Stable',
|
||||
source: 'osm',
|
||||
sourceId: 'osm:stable',
|
||||
});
|
||||
for (let i = 0; i < 50; i += 1)
|
||||
store.rememberQuery(`alias-${i}`, area.areaId);
|
||||
assert.equal(store.recallQuery('alias-0'), null, 'old aliases are evicted');
|
||||
assert.equal(store.recallQuery('alias-46'), null, 'the bound is independent');
|
||||
assert.equal(store.recallQuery('alias-47')?.areaId, area.areaId);
|
||||
assert.equal(store.recallQuery('alias-49')?.areaId, area.areaId);
|
||||
assert.equal(store.size, 1, 'alias churn does not evict the area record');
|
||||
});
|
||||
|
||||
test('a stable source keeps one handle when stored again', () => {
|
||||
const store = createAreaStore();
|
||||
for (let i = 0; i < 2; i += 1)
|
||||
store.put({
|
||||
geometry: [[square(0, 0, 1, 1)]],
|
||||
name: 'X',
|
||||
source: 'natural-earth',
|
||||
sourceId: 'ne:state:x',
|
||||
});
|
||||
assert.equal(store.size, 1);
|
||||
});
|
||||
|
||||
test('a named natural region resolves offline with every part', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'the Alps' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.record.source, 'natural-earth');
|
||||
assert.equal(result.record.parts, 2);
|
||||
assert.equal(calls.outline.length, 0, 'no lookup');
|
||||
assert.ok(result.ms < 300);
|
||||
});
|
||||
|
||||
test('a bundled state resolves by name with no lookup', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'Texas' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.rung, 'bundled');
|
||||
assert.equal(result.record.level, 'admin1');
|
||||
assert.equal(result.record.source, 'natural-earth');
|
||||
assert.equal(result.record.meta.country, 'United States of America');
|
||||
assert.equal(calls.outline.length, 0);
|
||||
});
|
||||
|
||||
test('Bagmati goes to the outline ladder and keeps its hole', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'Bagmati province' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.rung, 'nominatim');
|
||||
assert.equal(result.record.source, 'osm');
|
||||
assert.equal(result.record.level, 'admin1', 'the level word is kept');
|
||||
assert.equal(result.record.holes, 1);
|
||||
assert.equal(pointInPreparedArea(result.record.prepared, 27.2, 85), true);
|
||||
assert.equal(
|
||||
pointInPreparedArea(result.record.prepared, 27.65, 85.35),
|
||||
false,
|
||||
'inside the hole',
|
||||
);
|
||||
assert.equal(calls.outline[0].levelHint, 'admin1');
|
||||
});
|
||||
|
||||
test('a legacy bundled zone is never stored as the requested current province', async () => {
|
||||
const legacy = admin(
|
||||
'state',
|
||||
'Bagmati Zone',
|
||||
'Nepal',
|
||||
[84.645, 27.357, 86.055, 28.335],
|
||||
);
|
||||
const { store, resolver } = makeResolver({
|
||||
findAdminCandidates: async () => [legacy],
|
||||
});
|
||||
const result = await resolver.resolve({ query: 'Bagmati province' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.code, 'AREA_IDENTITY_UNVERIFIED');
|
||||
assert.equal(result.record, undefined);
|
||||
assert.equal(store.size, 0);
|
||||
const repeated = await resolver.resolve({ query: 'Bagmati province' });
|
||||
assert.equal(repeated.code, 'AREA_IDENTITY_UNVERIFIED');
|
||||
assert.equal(repeated.cached, false);
|
||||
assert.equal(store.size, 0);
|
||||
});
|
||||
|
||||
test('legacy zone identity cannot escape through view choice or candidate memory', async () => {
|
||||
const nepal = admin(
|
||||
'state',
|
||||
'Bagmati Zone',
|
||||
'Nepal',
|
||||
[84.645, 27.357, 86.055, 28.335],
|
||||
);
|
||||
const namesake = admin(
|
||||
'state',
|
||||
'Bagmati Zone',
|
||||
'Elsewhere',
|
||||
[10, 10, 11, 11],
|
||||
);
|
||||
const { store, resolver } = makeResolver({
|
||||
findAdminCandidates: async () => [nepal, namesake],
|
||||
viewCenter: () => ({ lat: 27.7, lon: 85.3 }),
|
||||
});
|
||||
const result = await resolver.resolve({ query: 'Bagmati province' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.code, 'AREA_IDENTITY_UNVERIFIED');
|
||||
assert.equal(result.needsClarification, undefined);
|
||||
assert.equal(result.candidates, undefined);
|
||||
assert.equal(store.size, 0);
|
||||
assert.equal(
|
||||
(await resolver.resolve({ candidateId: 'ne:state:nepal-bagmati-zone' }))
|
||||
.code,
|
||||
'UNKNOWN_CANDIDATE',
|
||||
);
|
||||
assert.equal(store.size, 0);
|
||||
});
|
||||
|
||||
test('a repeat is answered from the store without a lookup', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
await resolver.resolve({ query: 'Bagmati' });
|
||||
const again = await resolver.resolve({ query: 'bagmati' });
|
||||
assert.equal(again.ok, true);
|
||||
assert.equal(again.cached, true);
|
||||
assert.equal(calls.outline.length, 1);
|
||||
});
|
||||
|
||||
test('an ambiguous name asks instead of guessing, and the choice resolves', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'Georgia' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.needsClarification, true);
|
||||
assert.equal(result.candidates.length, 2);
|
||||
assert.deepEqual(result.candidates[0], {
|
||||
candidateId: 'ne:state:united-states-of-america-georgia',
|
||||
name: 'Georgia',
|
||||
level: 'admin1',
|
||||
country: 'United States of America',
|
||||
});
|
||||
assert.equal(result.candidates[1].level, 'country');
|
||||
assert.equal(calls.outline.length, 0, 'no lookup spent on a question');
|
||||
const chosen = await resolver.resolve({
|
||||
candidateId: result.candidates[0].candidateId,
|
||||
});
|
||||
assert.equal(chosen.ok, true);
|
||||
assert.equal(chosen.record.meta.country, 'United States of America');
|
||||
});
|
||||
|
||||
test('an already-aborted clarification choice does not persist page state', async () => {
|
||||
const { store, resolver } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'Georgia' });
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const chosen = await resolver.resolve(
|
||||
{ candidateId: result.candidates[0].candidateId },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
assert.equal(chosen.code, 'CANCELLED');
|
||||
assert.equal(chosen.cancelled, true);
|
||||
assert.equal(store.size, 0);
|
||||
});
|
||||
|
||||
test('candidate IDs accept every emitted choice and reject malformed or oversized input', async () => {
|
||||
const { store, resolver } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'Georgia' });
|
||||
const grammar = new RegExp(AREA_CANDIDATE_ID_PATTERN, 'u');
|
||||
for (const choice of result.candidates) {
|
||||
assert.ok(choice.candidateId.length <= MAX_AREA_CANDIDATE_ID_LENGTH);
|
||||
assert.match(choice.candidateId, grammar);
|
||||
assert.equal(isAreaCandidateId(choice.candidateId), true);
|
||||
assert.equal(
|
||||
(await resolver.resolve({ candidateId: choice.candidateId })).ok,
|
||||
true,
|
||||
);
|
||||
}
|
||||
assert.equal(result.candidates[0].candidateId.length, 41);
|
||||
|
||||
store.put({
|
||||
geometry: [[square(0, 0, 1, 1)]],
|
||||
name: 'Not a candidate',
|
||||
source: 'osm',
|
||||
sourceId: 'area-1',
|
||||
});
|
||||
for (const bad of [
|
||||
'area-1',
|
||||
'ne:state:bad slug',
|
||||
'ne:state:../../etc',
|
||||
'ne:state:!!!',
|
||||
'ne:state:a_b',
|
||||
'ne:planet:earth',
|
||||
`ne:state:${'a'.repeat(MAX_AREA_CANDIDATE_ID_LENGTH)}`,
|
||||
]) {
|
||||
const rejected = await resolver.resolve({ candidateId: bad });
|
||||
assert.equal(rejected.code, 'BAD_CANDIDATE');
|
||||
}
|
||||
});
|
||||
|
||||
test('long bundled names produce bounded, distinct candidate handles', async () => {
|
||||
const name = 'A'.repeat(70);
|
||||
const candidates = [
|
||||
admin('state', name, `B${'b'.repeat(70)}`, [0, 0, 1, 1]),
|
||||
admin('state', name, `C${'c'.repeat(70)}`, [2, 2, 3, 3]),
|
||||
];
|
||||
const { resolver } = makeResolver({
|
||||
findAdminCandidates: async () => candidates,
|
||||
});
|
||||
const result = await resolver.resolve({ query: name });
|
||||
assert.equal(result.needsClarification, true);
|
||||
assert.equal(
|
||||
new Set(result.candidates.map((choice) => choice.candidateId)).size,
|
||||
2,
|
||||
);
|
||||
for (const choice of result.candidates) {
|
||||
assert.equal(isAreaCandidateId(choice.candidateId), true);
|
||||
assert.ok(choice.candidateId.length <= MAX_AREA_CANDIDATE_ID_LENGTH);
|
||||
assert.equal(
|
||||
(await resolver.resolve({ candidateId: choice.candidateId })).ok,
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('within or level settles an ambiguous name', async () => {
|
||||
const { resolver } = makeResolver();
|
||||
const state = await resolver.resolve({
|
||||
query: 'Georgia',
|
||||
within: 'United States',
|
||||
});
|
||||
assert.equal(state.ok, true);
|
||||
assert.equal(state.record.level, 'admin1');
|
||||
const country = await resolver.resolve({
|
||||
query: 'Georgia',
|
||||
level: 'country',
|
||||
});
|
||||
assert.equal(country.ok, true);
|
||||
assert.equal(country.record.level, 'country');
|
||||
const both = await resolver.resolve({ query: 'Punjab', level: 'admin1' });
|
||||
assert.equal(both.needsClarification, true, 'both Punjabs are admin1');
|
||||
const pk = await resolver.resolve({ query: 'Punjab', within: 'Pakistan' });
|
||||
assert.equal(pk.record.meta.country, 'Pakistan');
|
||||
});
|
||||
|
||||
test('the view settles an ambiguous name only when it is inside one of them', async () => {
|
||||
const { resolver } = makeResolver({
|
||||
viewCenter: () => ({ lat: 31, lon: 75.5 }),
|
||||
});
|
||||
const result = await resolver.resolve({ query: 'Punjab' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.record.meta.country, 'India');
|
||||
const away = makeResolver({ viewCenter: () => ({ lat: 0, lon: 0 }) });
|
||||
const asked = await away.resolver.resolve({ query: 'Punjab' });
|
||||
assert.equal(asked.needsClarification, true);
|
||||
// A choice the view made is not remembered for the name.
|
||||
let view = { lat: 31, lon: 75.5 };
|
||||
const moving = makeResolver({ viewCenter: () => view });
|
||||
await moving.resolver.resolve({ query: 'Punjab' });
|
||||
view = { lat: 31, lon: 71 };
|
||||
const again = await moving.resolver.resolve({ query: 'Punjab' });
|
||||
assert.equal(again.record.meta.country, 'Pakistan');
|
||||
});
|
||||
|
||||
test('a qualifier skips the natural-region pack, which cannot check it', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
await resolver.resolve({ query: 'the Alps', within: 'Austria' });
|
||||
assert.equal(calls.natural, 0);
|
||||
assert.equal(calls.outline.length, 1);
|
||||
});
|
||||
|
||||
test('the area just stored survives eviction even when every other area is pinned', () => {
|
||||
const store = createAreaStore({ maxEntries: 2, isPinned: () => true });
|
||||
for (let i = 0; i < 3; i += 1)
|
||||
store.put({
|
||||
geometry: [[square(i, 0, i + 0.5, 0.5)]],
|
||||
name: `A${i}`,
|
||||
source: 'drawn',
|
||||
});
|
||||
const last = store.put({
|
||||
geometry: [[square(9, 0, 9.5, 0.5)]],
|
||||
name: 'new',
|
||||
source: 'drawn',
|
||||
});
|
||||
assert.ok(store.get(last.areaId), 'the new handle is usable');
|
||||
});
|
||||
|
||||
test('a country stands for its same-named state; namesakes abroad are rivals', () => {
|
||||
const mexico = admin('country', 'Mexico', 'Mexico', [-118, 14, -86, 33]);
|
||||
const state = admin(
|
||||
'state',
|
||||
'State of Mexico',
|
||||
'Mexico',
|
||||
[-100, 18, -98, 20],
|
||||
);
|
||||
assert.deepEqual(distinctAdminPlaces([state, mexico]), [mexico]);
|
||||
assert.equal(distinctAdminPlaces([PUNJAB_IN, PUNJAB_PK]).length, 2);
|
||||
});
|
||||
|
||||
test('bundled candidates: shared prominent names list every unit, minor ones none', async () => {
|
||||
const names = async (q) =>
|
||||
(await findAdminCandidates(q)).map((a) => `${a.kind}:${a.country}`);
|
||||
assert.deepEqual((await names('Punjab')).sort(), [
|
||||
'state:India',
|
||||
'state:Pakistan',
|
||||
]);
|
||||
assert.deepEqual((await names('Georgia')).sort(), [
|
||||
'country:Georgia',
|
||||
'state:United States of America',
|
||||
]);
|
||||
assert.deepEqual(await names('Punjab, India'), ['state:India']);
|
||||
assert.deepEqual(await names('Kent'), [], 'minor units are the geocoder’s');
|
||||
});
|
||||
|
||||
test('an outline ladder refusal passes through with its code', async () => {
|
||||
const { resolver } = makeResolver();
|
||||
const miss = await resolver.resolve({ query: 'Nowhere Province' });
|
||||
assert.equal(miss.ok, false);
|
||||
assert.equal(miss.code, 'AREA_NOT_FOUND');
|
||||
const down = makeResolver({
|
||||
resolveNamedOutline: async () => ({
|
||||
ok: false,
|
||||
code: 'AREA_UNAVAILABLE',
|
||||
error: 'No outline source can trace it here.',
|
||||
}),
|
||||
});
|
||||
const failed = await down.resolver.resolve({ query: 'Kathmandu' });
|
||||
assert.equal(failed.code, 'AREA_UNAVAILABLE');
|
||||
});
|
||||
|
||||
test('an approximate outline is stored and labelled as such', async () => {
|
||||
const { resolver } = makeResolver({
|
||||
resolveNamedOutline: async () => ({
|
||||
ok: true,
|
||||
name: 'Mission',
|
||||
polygons: [[square(0, 0, 0.01, 0.01)]],
|
||||
source: 'osm',
|
||||
rung: 'overpass',
|
||||
approximate: true,
|
||||
basis: 'buffer sized to the place',
|
||||
}),
|
||||
});
|
||||
const result = await resolver.resolve({ query: 'the Mission' });
|
||||
assert.equal(result.record.approximate, true);
|
||||
assert.equal(result.record.source, 'approximate');
|
||||
assert.equal(result.record.meta.basis, 'buffer sized to the place');
|
||||
});
|
||||
|
||||
test('a slow lookup times out but keeps filling the cache', async () => {
|
||||
let release;
|
||||
const gate = new Promise((r) => {
|
||||
release = r;
|
||||
});
|
||||
const { resolver } = makeResolver({
|
||||
resolveNamedOutline: async () => {
|
||||
await gate;
|
||||
return BAGMATI;
|
||||
},
|
||||
});
|
||||
const first = await resolver.resolve({ query: 'Bagmati' }, { budgetMs: 20 });
|
||||
assert.equal(first.code, 'AREA_TIMEOUT');
|
||||
release();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const second = await resolver.resolve({ query: 'Bagmati' }, { budgetMs: 20 });
|
||||
assert.equal(second.ok, true);
|
||||
assert.equal(second.cached, true);
|
||||
});
|
||||
|
||||
test('a cancelled caller returns at once without cancelling the shared lookup', async () => {
|
||||
const { resolver } = makeResolver();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const result = await resolver.resolve(
|
||||
{ query: 'Bagmati' },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
assert.equal(result.code, 'CANCELLED');
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const again = await resolver.resolve({ query: 'Bagmati' });
|
||||
assert.equal(again.ok, true);
|
||||
});
|
||||
|
||||
test('distinct area lookups are hard-bounded while same-key callers still coalesce', async () => {
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let calls = 0;
|
||||
const { resolver } = makeResolver({
|
||||
maxInFlight: 1,
|
||||
resolveNamedOutline: async (query) => {
|
||||
calls += 1;
|
||||
await gate;
|
||||
return { ...BAGMATI, name: query };
|
||||
},
|
||||
});
|
||||
const first = resolver.resolve({ query: 'First Area' });
|
||||
const joined = resolver.resolve({ query: 'First Area' });
|
||||
const refused = await resolver.resolve({ query: 'Second Area' });
|
||||
assert.equal(refused.code, 'AREA_BUSY');
|
||||
release();
|
||||
const [a, b] = await Promise.all([first, joined]);
|
||||
assert.equal(calls, 1, 'the same key shares the one live provider lookup');
|
||||
assert.equal(a.ok, true);
|
||||
assert.equal(b.ok, true);
|
||||
});
|
||||
|
||||
test("settled lookups leave no listener on the caller's signal", async () => {
|
||||
const { resolver } = makeResolver();
|
||||
const controller = new AbortController();
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
const add = controller.signal.addEventListener.bind(controller.signal);
|
||||
const remove = controller.signal.removeEventListener.bind(controller.signal);
|
||||
controller.signal.addEventListener = (...args) => {
|
||||
if (args[0] === 'abort') added += 1;
|
||||
return add(...args);
|
||||
};
|
||||
controller.signal.removeEventListener = (...args) => {
|
||||
if (args[0] === 'abort') removed += 1;
|
||||
return remove(...args);
|
||||
};
|
||||
for (const query of ['Texas', 'Bagmati', 'Nowhere at all'])
|
||||
await resolver.resolve({ query }, { signal: controller.signal });
|
||||
assert.ok(added > 0, 'the lookups listened while running');
|
||||
assert.equal(removed, added, 'every listener is detached once settled');
|
||||
|
||||
const aborted = AbortSignal.abort();
|
||||
let attached = 0;
|
||||
aborted.addEventListener = () => {
|
||||
attached += 1;
|
||||
};
|
||||
const late = await resolver.resolve(
|
||||
{ query: 'Punjab, Pakistan' },
|
||||
{ signal: aborted },
|
||||
);
|
||||
assert.equal(late.code, 'CANCELLED');
|
||||
assert.equal(attached, 0, 'an aborted signal gets no listener');
|
||||
});
|
||||
|
||||
test('resolver disposal aborts provider lifetime and prevents late store/cache writes', async () => {
|
||||
let release;
|
||||
let providerSignal;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const { resolver, store } = makeResolver({
|
||||
resolveNamedOutline: async (_query, { signal }) => {
|
||||
providerSignal = signal;
|
||||
await gate; // Deliberately ignores abort until it returns.
|
||||
return BAGMATI;
|
||||
},
|
||||
});
|
||||
const pending = resolver.resolve({ query: 'Late Area' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
resolver.dispose();
|
||||
assert.equal(providerSignal.aborted, true);
|
||||
release();
|
||||
const result = await pending;
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(store.size, 0, 'late provider data never reaches the store');
|
||||
const after = await resolver.resolve({ query: 'Late Area' });
|
||||
assert.equal(after.code, 'CANCELLED');
|
||||
});
|
||||
|
||||
test('level words map to area levels', () => {
|
||||
assert.deepEqual(splitLevelWord('Bagmati Province'), {
|
||||
bare: 'bagmati',
|
||||
level: 'admin1',
|
||||
});
|
||||
assert.deepEqual(splitLevelWord('Kathmandu'), {
|
||||
bare: 'kathmandu',
|
||||
level: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('non-Latin names are distinct keys: 東京 then 大阪 looks up both', async () => {
|
||||
const asked = [];
|
||||
const { resolver } = makeResolver({
|
||||
resolveNamedOutline: async (q) => {
|
||||
asked.push(q);
|
||||
return { ...BAGMATI, name: q };
|
||||
},
|
||||
});
|
||||
const first = await resolver.resolve({ query: '東京' });
|
||||
const second = await resolver.resolve({ query: '大阪' });
|
||||
assert.notEqual(first.record.areaId, second.record.areaId);
|
||||
assert.equal(second.cached, false, 'not the cached Tokyo');
|
||||
assert.deepEqual(asked, ['東京', '大阪']);
|
||||
// The same name with a different `within` is a different question.
|
||||
await resolver.resolve({ query: '東京', within: '日本' });
|
||||
assert.equal(asked.length, 3);
|
||||
assert.equal((await resolver.resolve({ query: '東京' })).cached, true);
|
||||
});
|
||||
|
||||
test('a name that normalizes to nothing is never cached or shared', async () => {
|
||||
let calls = 0;
|
||||
const { resolver } = makeResolver({
|
||||
resolveNamedOutline: async () => {
|
||||
calls += 1;
|
||||
return BAGMATI;
|
||||
},
|
||||
});
|
||||
await resolver.resolve({ query: '!!!' });
|
||||
await resolver.resolve({ query: '???' });
|
||||
assert.equal(calls, 2, 'two punctuation-only asks are two lookups');
|
||||
});
|
||||
|
||||
test('a natural region never hides a country or state of the same name', async () => {
|
||||
const desert = {
|
||||
name: 'Punjab',
|
||||
kind: 'natural',
|
||||
polygons: [square(70, 28, 76, 33)],
|
||||
};
|
||||
const { resolver } = makeResolver({
|
||||
findNaturalRegion: async (q) => (/^punjab$/i.test(q) ? desert : null),
|
||||
});
|
||||
const bare = await resolver.resolve({ query: 'Punjab' });
|
||||
assert.equal(bare.ok, false);
|
||||
assert.equal(bare.code, 'AMBIGUOUS_AREA', 'the two states are asked about');
|
||||
const natural = await resolver.resolve({ query: 'Punjab', level: 'natural' });
|
||||
assert.equal(natural.ok, true);
|
||||
assert.equal(natural.record.level, 'natural', 'asked for, the region wins');
|
||||
});
|
||||
|
||||
test('same-named states in one country get their own candidate and area', async () => {
|
||||
const wa = admin(
|
||||
'state',
|
||||
'Washington',
|
||||
'United States of America',
|
||||
[-124, 45.5, -117, 49],
|
||||
{ regionCode: 'WA' },
|
||||
);
|
||||
const dc = admin(
|
||||
'state',
|
||||
'Washington',
|
||||
'United States of America',
|
||||
[-77.2, 38.8, -76.9, 39],
|
||||
{ regionCode: 'DC' },
|
||||
);
|
||||
const { resolver } = makeResolver({
|
||||
findAdminCandidates: async (q) => (/^washington$/i.test(q) ? [wa, dc] : []),
|
||||
});
|
||||
const asked = await resolver.resolve({ query: 'Washington' });
|
||||
assert.equal(asked.code, 'AMBIGUOUS_AREA');
|
||||
const ids = asked.candidates.map((c) => c.candidateId);
|
||||
assert.equal(new Set(ids).size, 2, `distinct ids: ${ids.join(', ')}`);
|
||||
assert.deepEqual(
|
||||
asked.candidates.map((c) => c.region),
|
||||
['WA', 'DC'],
|
||||
'the choices can be told apart',
|
||||
);
|
||||
const picked = await Promise.all(
|
||||
ids.map((candidateId) => resolver.resolve({ candidateId })),
|
||||
);
|
||||
assert.ok(picked.every((p) => p.ok));
|
||||
assert.notEqual(
|
||||
picked[0].record.areaId,
|
||||
picked[1].record.areaId,
|
||||
'two areas, not one overwritten',
|
||||
);
|
||||
assert.equal(
|
||||
pointInPreparedArea(picked[0].record.prepared, 47, -120),
|
||||
true,
|
||||
'Washington State',
|
||||
);
|
||||
assert.equal(
|
||||
pointInPreparedArea(picked[1].record.prepared, 38.9, -77),
|
||||
true,
|
||||
'District of Columbia',
|
||||
);
|
||||
});
|
||||
|
||||
test('a bundled name mismatch falls through to the outline ladder', async () => {
|
||||
const azad = admin('state', 'Azad Kashmir', 'Pakistan', [73, 33, 75, 35.5]);
|
||||
const kashmir = {
|
||||
ok: true,
|
||||
name: 'Kashmir',
|
||||
polygons: [[square(73, 32, 80, 37)]],
|
||||
source: 'osm',
|
||||
rung: 'nominatim',
|
||||
};
|
||||
let outlineOk = true;
|
||||
const { resolver } = makeResolver({
|
||||
findAdminCandidates: async (q) => (/^kashmir$/i.test(q) ? [azad] : []),
|
||||
resolveNamedOutline: async () =>
|
||||
outlineOk
|
||||
? kashmir
|
||||
: { ok: false, code: 'AREA_NOT_FOUND', error: 'none' },
|
||||
});
|
||||
const found = await resolver.resolve({ query: 'Kashmir' });
|
||||
assert.equal(found.ok, true, found.error);
|
||||
assert.equal(found.record.name, 'Kashmir');
|
||||
outlineOk = false;
|
||||
const { resolver: offline } = makeResolver({
|
||||
findAdminCandidates: async () => [azad],
|
||||
resolveNamedOutline: async () => ({
|
||||
ok: false,
|
||||
code: 'AREA_NOT_FOUND',
|
||||
error: 'none',
|
||||
}),
|
||||
});
|
||||
const refused = await offline.resolve({ query: 'Kashmir' });
|
||||
assert.equal(
|
||||
refused.code,
|
||||
'AREA_IDENTITY_MISMATCH',
|
||||
'the bundled refusal is kept',
|
||||
);
|
||||
});
|
||||
|
||||
test('a view-picked outline answer is not reused for the same name elsewhere', async () => {
|
||||
let view = { lat: 39.8, lon: -89.6 }; // Springfield, Illinois
|
||||
const springfield = (center) => ({
|
||||
ok: true,
|
||||
name:
|
||||
center.lon < -80 ? 'Springfield, Illinois' : 'Springfield, Massachusetts',
|
||||
polygons: [
|
||||
[
|
||||
square(
|
||||
center.lon - 0.1,
|
||||
center.lat - 0.1,
|
||||
center.lon + 0.1,
|
||||
center.lat + 0.1,
|
||||
),
|
||||
],
|
||||
],
|
||||
source: 'osm',
|
||||
rung: 'nominatim',
|
||||
});
|
||||
const { resolver } = makeResolver({
|
||||
viewCenter: () => view,
|
||||
resolveNamedOutline: async () => springfield(view),
|
||||
});
|
||||
const illinois = await resolver.resolve({ query: 'Springfield' });
|
||||
assert.equal(illinois.record.name, 'Springfield, Illinois');
|
||||
const again = await resolver.resolve({ query: 'Springfield' });
|
||||
assert.equal(again.cached, true, 'the same view still reuses it');
|
||||
view = { lat: 42.1, lon: -72.6 }; // Springfield, Massachusetts
|
||||
const massachusetts = await resolver.resolve({ query: 'Springfield' });
|
||||
assert.equal(massachusetts.cached, false);
|
||||
assert.equal(massachusetts.record.name, 'Springfield, Massachusetts');
|
||||
});
|
||||
|
||||
test('a name spoken without accents matches its accented unit, Latin letters only', async () => {
|
||||
const quebec = admin('state', 'Québec', 'Canada', [-79.8, 45, -57.1, 62.6]);
|
||||
const cordobaAr = admin(
|
||||
'state',
|
||||
'Córdoba',
|
||||
'Argentina',
|
||||
[-65.8, -35, -61.8, -29.5],
|
||||
);
|
||||
const cordobaEs = admin('state', 'Córdoba', 'Spain', [-5.6, 37.2, -4, 38.7]);
|
||||
const thai = admin('state', 'ป่าย', 'Thailand', [98, 19, 99, 20]);
|
||||
const { resolver } = makeResolver({
|
||||
findAdminCandidates: async (q) =>
|
||||
/^quebec$/i.test(q)
|
||||
? [quebec]
|
||||
: /^cordoba$/i.test(q)
|
||||
? [cordobaAr, cordobaEs]
|
||||
: q === 'ปาย'
|
||||
? [thai]
|
||||
: [],
|
||||
});
|
||||
const qc = await resolver.resolve({ query: 'Quebec' });
|
||||
assert.equal(qc.ok, true, qc.error);
|
||||
assert.equal(qc.record.name, 'Québec');
|
||||
const cordoba = await resolver.resolve({ query: 'Cordoba' });
|
||||
assert.equal(cordoba.code, 'AMBIGUOUS_AREA', 'both Córdobas are asked about');
|
||||
assert.equal(cordoba.candidates.length, 2);
|
||||
const tone = await resolver.resolve({ query: 'ปาย' });
|
||||
assert.equal(tone.ok, false, 'a Thai tone mark still tells names apart');
|
||||
});
|
||||
|
||||
test('a natural name for two distant places is asked about, and the choice resolves', async () => {
|
||||
const azores = {
|
||||
name: 'Flores',
|
||||
featurecla: 'Island',
|
||||
polygons: [square(-31.3, 39.4, -31.1, 39.5)],
|
||||
areaKm2: 140,
|
||||
center: { lat: 39.45, lon: -31.2 },
|
||||
};
|
||||
const indonesia = {
|
||||
name: 'Flores',
|
||||
featurecla: 'Island',
|
||||
polygons: [square(119.8, -8.9, 123, -8.1)],
|
||||
areaKm2: 14000,
|
||||
center: { lat: -8.5, lon: 121.4 },
|
||||
};
|
||||
const { resolver } = makeResolver({
|
||||
findNaturalRegion: async (q) =>
|
||||
/^flores$/i.test(q)
|
||||
? {
|
||||
name: 'Flores',
|
||||
kind: 'natural',
|
||||
polygons: indonesia.polygons,
|
||||
places: [indonesia, azores],
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const asked = await resolver.resolve({ query: 'Flores' });
|
||||
assert.equal(asked.code, 'AMBIGUOUS_AREA');
|
||||
assert.equal(asked.candidates.length, 2);
|
||||
assert.ok(asked.candidates.every((c) => c.level === 'natural' && c.region));
|
||||
const picked = await resolver.resolve({
|
||||
candidateId: asked.candidates[1].candidateId,
|
||||
});
|
||||
assert.equal(picked.ok, true, picked.error);
|
||||
assert.equal(picked.record.level, 'natural');
|
||||
assert.equal(
|
||||
pointInPreparedArea(picked.record.prepared, 39.45, -31.2),
|
||||
true,
|
||||
'the Azores Flores',
|
||||
);
|
||||
});
|
||||
|
||||
test('a natural feature in several parts is stored whole', async () => {
|
||||
const north = square(33.7, 2.4, 44.2, 15.4);
|
||||
const south = square(28.9, -15.6, 35.8, 2.4);
|
||||
const { resolver } = makeResolver({
|
||||
findNaturalRegion: async (q) =>
|
||||
/rift/i.test(q)
|
||||
? {
|
||||
name: 'Great Rift Valley',
|
||||
kind: 'natural',
|
||||
polygons: [north],
|
||||
places: [
|
||||
{
|
||||
name: 'Great Rift Valley',
|
||||
featurecla: 'Valley',
|
||||
polygons: [north, south],
|
||||
areaKm2: 2,
|
||||
center: { lat: 0, lon: 36 },
|
||||
},
|
||||
],
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const rift = await resolver.resolve({ query: 'Great Rift Valley' });
|
||||
assert.equal(rift.ok, true);
|
||||
assert.equal(
|
||||
pointInPreparedArea(rift.record.prepared, -10, 33),
|
||||
true,
|
||||
'the southern part counts',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Session-scoped store of area handles. A voice turn refers to an area by its
|
||||
* `areaId`; the geometry itself stays here and never goes to the model.
|
||||
*
|
||||
* Records are immutable once stored. A source with a stable identity (an OSM
|
||||
* relation, a Natural Earth region, an annotation) keeps the same id when it
|
||||
* is stored again, so repeating "outline Bagmati" does not mint new handles.
|
||||
* Least recently used records are evicted past `maxEntries`, except those
|
||||
* `isPinned` reports as still in use.
|
||||
*
|
||||
* Pure: no Cesium, DOM or network.
|
||||
* @module data/areaStore
|
||||
*/
|
||||
|
||||
import {
|
||||
multiPolygonAreaKm2,
|
||||
multiPolygonCounts,
|
||||
normalizeMultiPolygon,
|
||||
prepareArea,
|
||||
} from './areaGeometry.js';
|
||||
|
||||
/** Where an area came from, as the model and the panel name it. */
|
||||
export const AREA_SOURCES = Object.freeze([
|
||||
'natural-earth',
|
||||
'us-census',
|
||||
'wof',
|
||||
'datasf',
|
||||
'osm',
|
||||
'openfreemap',
|
||||
'annotation',
|
||||
'drawn',
|
||||
'approximate',
|
||||
]);
|
||||
|
||||
/** Levels a resolved area can have. */
|
||||
export const AREA_LEVELS = Object.freeze([
|
||||
'country',
|
||||
'admin1',
|
||||
'admin2',
|
||||
'city',
|
||||
'district',
|
||||
'natural',
|
||||
'site',
|
||||
'drawn',
|
||||
]);
|
||||
|
||||
const round = (value, digits) => {
|
||||
const f = 10 ** digits;
|
||||
return Math.round(value * f) / f;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an area store.
|
||||
* @param {{maxEntries?: number, maxQueryEntries?: number, now?: () => number, isPinned?: (areaId: string) => boolean}} [options]
|
||||
*/
|
||||
export function createAreaStore({
|
||||
maxEntries = 32,
|
||||
maxQueryEntries = Math.max(32, maxEntries * 8),
|
||||
now = () => Date.now(),
|
||||
isPinned = () => false,
|
||||
} = {}) {
|
||||
/** Insertion order doubles as recency: get() re-inserts. */
|
||||
const records = new Map();
|
||||
/** query key → areaId, for cached resolutions. */
|
||||
const queries = new Map();
|
||||
let seq = 0;
|
||||
|
||||
function forgetRecordQueries(areaId) {
|
||||
for (const [key, queried] of queries)
|
||||
if (queried === areaId) queries.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop least recently used records past the bound, skipping pinned ones
|
||||
* (an area an outline on the map still refers to keeps its geometry for as
|
||||
* long as the outline exists; the annotation cap bounds how many that is).
|
||||
*/
|
||||
function evict(keep) {
|
||||
if (records.size <= maxEntries) return;
|
||||
for (const id of [...records.keys()]) {
|
||||
if (records.size <= maxEntries) break;
|
||||
// The record just stored is always kept; pinned ones may exceed the bound.
|
||||
if (id === keep || isPinned(id)) continue;
|
||||
records.delete(id);
|
||||
forgetRecordQueries(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an area. Returns the stored record, or null when the geometry is
|
||||
* unusable.
|
||||
* @param {object} input
|
||||
* @param {object|Array} input.geometry GeoJSON Polygon/MultiPolygon or coordinates.
|
||||
* @param {string} input.name
|
||||
* @param {string} input.source One of AREA_SOURCES.
|
||||
* @param {string} [input.sourceId] Stable identity, e.g. `ne:state:india-punjab`.
|
||||
* @param {string} [input.level]
|
||||
* @param {boolean} [input.approximate]
|
||||
* @param {object} [input.meta] Extra facts (country, license, completeness).
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function put(input) {
|
||||
const geometry = normalizeMultiPolygon(input?.geometry);
|
||||
if (!geometry) return null;
|
||||
const prepared = prepareArea(geometry);
|
||||
if (!prepared) return null;
|
||||
const areaId = input.sourceId
|
||||
? String(input.sourceId)
|
||||
: `area-${(seq += 1)}`;
|
||||
const counts = multiPolygonCounts(geometry);
|
||||
const record = Object.freeze({
|
||||
areaId,
|
||||
name: String(input.name || 'Unnamed area').slice(0, 160),
|
||||
level: AREA_LEVELS.includes(input.level) ? input.level : null,
|
||||
source: AREA_SOURCES.includes(input.source)
|
||||
? input.source
|
||||
: 'approximate',
|
||||
approximate: Boolean(input.approximate),
|
||||
geometry,
|
||||
prepared,
|
||||
bbox: prepared.bbox,
|
||||
areaKm2: multiPolygonAreaKm2(geometry),
|
||||
parts: counts.parts,
|
||||
holes: counts.holes,
|
||||
meta: Object.freeze({ ...(input.meta || {}) }),
|
||||
storedAt: now(),
|
||||
});
|
||||
records.delete(areaId);
|
||||
records.set(areaId, record);
|
||||
evict(areaId);
|
||||
return record;
|
||||
}
|
||||
|
||||
/** The record for an id, marking it recently used; null when unknown or evicted. */
|
||||
function get(areaId) {
|
||||
const record = records.get(String(areaId ?? ''));
|
||||
if (!record) return null;
|
||||
records.delete(record.areaId);
|
||||
records.set(record.areaId, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
return {
|
||||
put,
|
||||
get,
|
||||
has: (areaId) => records.has(String(areaId ?? '')),
|
||||
/** Remember which area a query resolved to. */
|
||||
rememberQuery(key, areaId) {
|
||||
if (!key || !records.has(areaId)) return;
|
||||
queries.delete(key);
|
||||
queries.set(key, areaId);
|
||||
while (queries.size > maxQueryEntries)
|
||||
queries.delete(queries.keys().next().value);
|
||||
},
|
||||
/** The area a query resolved to earlier, if still stored. */
|
||||
recallQuery(key) {
|
||||
const id = queries.get(key);
|
||||
const record = id ? get(id) : null;
|
||||
if (!record) {
|
||||
queries.delete(key);
|
||||
return null;
|
||||
}
|
||||
queries.delete(key);
|
||||
queries.set(key, id);
|
||||
return record;
|
||||
},
|
||||
/** Newest first. */
|
||||
list: () => [...records.values()].reverse(),
|
||||
/** The most recently stored or used record matching `predicate`. */
|
||||
latest(predicate = () => true) {
|
||||
return [...records.values()].reverse().find(predicate) || null;
|
||||
},
|
||||
delete(areaId) {
|
||||
const id = String(areaId ?? '');
|
||||
const deleted = records.delete(id);
|
||||
if (deleted) forgetRecordQueries(id);
|
||||
return deleted;
|
||||
},
|
||||
clear() {
|
||||
records.clear();
|
||||
queries.clear();
|
||||
},
|
||||
get size() {
|
||||
return records.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The model-facing summary of an area: identity, size and provenance, never
|
||||
* coordinates beyond a rounded bbox.
|
||||
* @param {object} record
|
||||
* @returns {object}
|
||||
*/
|
||||
export function summarizeArea(record) {
|
||||
if (!record) return null;
|
||||
return {
|
||||
areaId: record.areaId,
|
||||
name: record.name,
|
||||
level: record.level,
|
||||
source: record.source,
|
||||
...(record.meta.country ? { country: record.meta.country } : {}),
|
||||
bbox: record.bbox.map((v) => round(v, 3)),
|
||||
areaKm2:
|
||||
record.areaKm2 >= 100
|
||||
? Math.round(record.areaKm2)
|
||||
: round(record.areaKm2, 2),
|
||||
parts: record.parts,
|
||||
holes: record.holes,
|
||||
...(record.approximate ? { approximate: true } : {}),
|
||||
};
|
||||
}
|
||||
@@ -150,6 +150,20 @@ export const DATA_CREDITS = [
|
||||
key: 'usgs',
|
||||
html: 'Earthquakes: Data courtesy of the U.S. Geological Survey',
|
||||
},
|
||||
{
|
||||
key: 'openseamap',
|
||||
html:
|
||||
'Sea marks overlay: ' +
|
||||
'<a href="https://www.openseamap.org" target="_blank" rel="noopener">© OpenSeaMap contributors</a> (CC BY-SA 2.0) · ' +
|
||||
'Data © OpenStreetMap contributors (ODbL)',
|
||||
},
|
||||
{
|
||||
key: 'opensnowmap',
|
||||
html:
|
||||
'Ski pistes overlay: ' +
|
||||
'<a href="https://www.opensnowmap.org" target="_blank" rel="noopener">© www.opensnowmap.org</a> (CC BY-SA) · ' +
|
||||
'Data © OpenStreetMap contributors (ODbL)',
|
||||
},
|
||||
{
|
||||
key: 'nasa-gibs',
|
||||
html:
|
||||
@@ -565,6 +579,15 @@ export const WOF_CREDIT = {
|
||||
'<a href="https://github.com/bilawalsidhu/gods-eye-view/blob/main/src/data/local_data/wof_neighborhoods/ATTRIBUTION.md" target="_blank" rel="noopener">sources and licences</a>)',
|
||||
};
|
||||
|
||||
/** Registered when a voice place search (a configured Overpass) first shows places. */
|
||||
export const OSM_PLACES_CREDIT = {
|
||||
key: 'osm-places',
|
||||
html:
|
||||
'Places found by voice: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-feed transit credit, registered the first time that feed's vehicles
|
||||
* render (see `src/data/transitFeeds.js` for the license of each).
|
||||
|
||||
@@ -53,6 +53,12 @@ import {
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
isCyberContactThemeActive as isCyberMapThemeActive,
|
||||
} from '../cyberSonar.js';
|
||||
import {
|
||||
awarenessEchoActive,
|
||||
awarenessEchoBracket,
|
||||
awarenessEchoColor,
|
||||
getAwarenessEchoField,
|
||||
} from '../awarenessEcho.js';
|
||||
|
||||
/**
|
||||
* @module detection
|
||||
@@ -103,6 +109,8 @@ const SPARSE_ZONE_FRACTION = 0.5;
|
||||
const LABEL_SOLVE_INTERVAL_MS = 125;
|
||||
/** Alpha bands retain bracket batching while approximating a continuous radial fade. */
|
||||
const BRACKET_ALPHA_STEPS = 4;
|
||||
/** Finer bands for Global Context echo brackets, whose fade is animated. */
|
||||
const ECHO_ALPHA_STEPS = 8;
|
||||
/** Stable per-layer candidate safety cap; independent of density and camera bearing. */
|
||||
const LAYER_CANDIDATE_CAP = 2600;
|
||||
const LAYER_WEIGHTS = Object.freeze({
|
||||
@@ -1335,6 +1343,11 @@ function _drawOverlay(frame) {
|
||||
const tiers = _theme.tiers || null;
|
||||
const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
|
||||
const bracketPaths = new Map();
|
||||
// Global Context sonar echoes: a second bracket, banded by strength, around
|
||||
// each flight or vessel a pulse front has just crossed.
|
||||
// Resting pulses light nothing, so the per-contact echo work is skipped.
|
||||
const echoField = awarenessEchoActive(now) ? getAwarenessEchoField() : null;
|
||||
const echoPaths = echoField ? new Array(ECHO_ALPHA_STEPS + 1) : null;
|
||||
const pathFor = (map, color, alpha) => {
|
||||
const band = Math.max(
|
||||
1,
|
||||
@@ -1437,6 +1450,24 @@ function _drawOverlay(frame) {
|
||||
halfH,
|
||||
);
|
||||
visibleCount++;
|
||||
if (echoPaths) {
|
||||
// Starts on the contact's bracket and spreads outward as it fades.
|
||||
const echo = awarenessEchoBracket(
|
||||
obj._layerId,
|
||||
obj.position,
|
||||
now,
|
||||
bracketAlpha,
|
||||
ECHO_ALPHA_STEPS,
|
||||
);
|
||||
if (echo)
|
||||
appendCornerBracket(
|
||||
(echoPaths[echo.band] ??= new Path2D()),
|
||||
sx,
|
||||
sy,
|
||||
halfW + echo.spreadPx,
|
||||
halfH + echo.spreadPx,
|
||||
);
|
||||
}
|
||||
if (obj.type === 'AIR')
|
||||
aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
|
||||
if (bracketAlpha >= 1) bracketOpacityCounts.full++;
|
||||
@@ -1576,6 +1607,20 @@ function _drawOverlay(frame) {
|
||||
_ctx.stroke(entry.path);
|
||||
}
|
||||
}
|
||||
if (echoPaths) {
|
||||
_ctx.lineWidth = echoField.echo.lineWidth;
|
||||
_ctx.strokeStyle = awarenessEchoColor(
|
||||
_theme.line,
|
||||
_theme === THEME_MAP._default,
|
||||
);
|
||||
for (let band = 1; band <= ECHO_ALPHA_STEPS; band++) {
|
||||
if (!echoPaths[band]) continue;
|
||||
_ctx.globalAlpha =
|
||||
fade * (band / ECHO_ALPHA_STEPS) * bracketPresentationOpacity;
|
||||
_ctx.stroke(echoPaths[band]);
|
||||
}
|
||||
_ctx.lineWidth = bracketWidth;
|
||||
}
|
||||
|
||||
const renderEntries = _labelArbiter.renderEntries(candidateMap, now);
|
||||
const fadingCount = countFadingRenderEntries(renderEntries);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// src/data/detectionAwarenessEcho.test.mjs — Global Context sonar echoes pulse
|
||||
// a contact's own detection bracket: a second bracket that starts on it and
|
||||
// spreads outward as it fades, in the detection style's own colour.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
awarenessEchoBracket,
|
||||
awarenessEchoColor,
|
||||
clearAwarenessEchoField,
|
||||
setAwarenessEchoField,
|
||||
} from '../awarenessEcho.js';
|
||||
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
|
||||
|
||||
const STEPS = 8;
|
||||
const pulse = { count: 3, periodMs: 9000, startFraction: 0.02 };
|
||||
const echo = {
|
||||
fadeMs: 1400,
|
||||
layerIds: ['flights', 'military', 'ais-live-vessels'],
|
||||
spreadPx: 8,
|
||||
color: 'rgb(98, 181, 255)',
|
||||
};
|
||||
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
|
||||
const field = { center, radiusM: 250_000, pulse, echo };
|
||||
|
||||
/** The moment pulse 0's front reaches the ground under `position`. */
|
||||
function arrivalAt(position) {
|
||||
const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(position);
|
||||
const d = Cesium.Cartesian3.distance(center, ground);
|
||||
return (
|
||||
((d / 250_000 - pulse.startFraction) / (1 - pulse.startFraction)) *
|
||||
pulse.periodMs +
|
||||
pulse.periodMs * 3
|
||||
);
|
||||
}
|
||||
|
||||
test('the echo bracket pulses outward from the contact bracket and fades', (t) => {
|
||||
t.after(clearAwarenessEchoField);
|
||||
setAwarenessEchoField(field);
|
||||
const position = Cesium.Cartesian3.fromDegrees(3.5, 27, 10_000);
|
||||
const arrival = arrivalAt(position);
|
||||
const at = (nowMs, bracketAlpha = 1) =>
|
||||
awarenessEchoBracket('flights', position, nowMs, bracketAlpha, STEPS);
|
||||
|
||||
// The moment the front arrives: on the bracket, strongest band.
|
||||
const fresh = at(arrival);
|
||||
assert.ok(fresh);
|
||||
assert.ok(Math.abs(fresh.spreadPx) < 0.01, `${fresh.spreadPx}`);
|
||||
assert.equal(fresh.band, STEPS);
|
||||
// Half way through the fade: dimmer and spread outward, but not past
|
||||
// the field's spread.
|
||||
const half = at(arrival + echo.fadeMs / 2);
|
||||
assert.ok(half.spreadPx > echo.spreadPx * 0.5);
|
||||
assert.ok(half.spreadPx < echo.spreadPx);
|
||||
assert.ok(half.band < STEPS && half.band >= 1);
|
||||
// The bracket's own dimming (keyhole fade, Cyber sonar sweep) carries
|
||||
// over, and the echo is never drawn brighter than its bracket: a bracket
|
||||
// too dim for even one band draws no echo.
|
||||
assert.equal(at(arrival, 0.25).band, 2);
|
||||
assert.equal(at(arrival, 0.05), null);
|
||||
for (let alpha = 0.05; alpha <= 1; alpha += 0.05) {
|
||||
const echoed = at(arrival + echo.fadeMs / 3, alpha);
|
||||
if (echoed)
|
||||
assert.ok(
|
||||
echoed.band / STEPS <= alpha + 0.5 / STEPS + 1e-9,
|
||||
`band ${echoed.band} for a bracket at ${alpha}`,
|
||||
);
|
||||
}
|
||||
// Faded out, or between fronts: no echo bracket.
|
||||
assert.equal(at(arrival + echo.fadeMs + 5), null);
|
||||
});
|
||||
|
||||
test('no echo field, or a layer that does not echo, draws nothing extra', (t) => {
|
||||
t.after(clearAwarenessEchoField);
|
||||
const position = Cesium.Cartesian3.fromDegrees(3.01, 27, 0);
|
||||
clearAwarenessEchoField();
|
||||
for (let now = 0; now < pulse.periodMs; now += 25)
|
||||
assert.equal(
|
||||
awarenessEchoBracket('flights', position, now, 1, STEPS),
|
||||
null,
|
||||
);
|
||||
setAwarenessEchoField(field);
|
||||
for (let now = 0; now < pulse.periodMs; now += 25)
|
||||
for (const layerId of ['cctv', 'satellites', 'traffic'])
|
||||
assert.equal(
|
||||
awarenessEchoBracket(layerId, position, now, 1, STEPS),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('echoes take the detection style colour except on the default style', (t) => {
|
||||
t.after(clearAwarenessEchoField);
|
||||
assert.equal(
|
||||
awarenessEchoColor(DETECTION_THEME_MAP._default.line, true),
|
||||
null,
|
||||
);
|
||||
setAwarenessEchoField(field);
|
||||
assert.equal(
|
||||
awarenessEchoColor(DETECTION_THEME_MAP._default.line, true),
|
||||
echo.color,
|
||||
);
|
||||
// CRT, NVG and FLIR keep their monochrome palettes.
|
||||
for (const style of ['retro', 'surveillance', 'thermal']) {
|
||||
const { line } = DETECTION_THEME_MAP[style];
|
||||
assert.equal(awarenessEchoColor(line, false), line, style);
|
||||
}
|
||||
});
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
setOverlayEntries,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
|
||||
import {
|
||||
clearAwarenessEchoField,
|
||||
setAwarenessEchoField,
|
||||
} from '../awarenessEcho.js';
|
||||
import { composeLabel, resolveTier } from './detectionDraw.js';
|
||||
|
||||
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
|
||||
@@ -50,8 +54,9 @@ class MockEvent {
|
||||
}
|
||||
|
||||
class MockPath2D {
|
||||
moveTo() {}
|
||||
lineTo() {}
|
||||
constructor() { this.points = []; }
|
||||
moveTo(x, y) { this.points.push([x, y]); }
|
||||
lineTo(x, y) { this.points.push([x, y]); }
|
||||
roundRect() {}
|
||||
arcTo() {}
|
||||
closePath() {}
|
||||
@@ -98,7 +103,7 @@ function mockContext(target, trace) {
|
||||
// set at some point during the frame. globalAlpha rides along for the same
|
||||
// reason: the backdrop feather is an alpha, not a colour.
|
||||
fill(path) { record('fill', path, this.fillStyle, this.globalAlpha); },
|
||||
stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth); },
|
||||
stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth, this.globalAlpha); },
|
||||
fillRect(...args) { record('fillRect', ...args); },
|
||||
fillText(...args) { record('fillText', ...args); },
|
||||
drawImage(...args) { record('drawImage', ...args); },
|
||||
@@ -627,6 +632,231 @@ test('surveillance military tier reaches the dedicated render target as shipped
|
||||
}
|
||||
});
|
||||
|
||||
test('Global Context echo brackets reach the canvas in the detection style colour', () => {
|
||||
const env = installEnvironment();
|
||||
// Fronts every millisecond with a long fade, so the contact is lit on
|
||||
// whatever frame paints; a distinctive width picks the echo stroke out.
|
||||
const echo = {
|
||||
layerIds: ['military'],
|
||||
fadeMs: 60_000,
|
||||
color: 'rgb(1, 2, 3)',
|
||||
spreadPx: 8,
|
||||
lineWidth: 3.25,
|
||||
};
|
||||
const echoStrokes = () =>
|
||||
env.detectionCtx.calls
|
||||
.filter(([name, , , width]) => name === 'stroke' && width === echo.lineWidth)
|
||||
.map(([, , style]) => style);
|
||||
try {
|
||||
setAwarenessEchoField({
|
||||
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
|
||||
radiusM: 250_000,
|
||||
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
|
||||
echo,
|
||||
});
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [militaryLayer()], () => {});
|
||||
setMode('DENSE');
|
||||
setDetectionStyle('normal');
|
||||
settleFrame(env);
|
||||
assert.ok(echoStrokes().length > 0, 'the echo bracket is painted');
|
||||
assert.ok(echoStrokes().every((style) => style === echo.color));
|
||||
|
||||
// CRT, NVG and FLIR keep their own palettes.
|
||||
for (const style of ['retro', 'surveillance', 'thermal']) {
|
||||
env.detectionCtx.calls.length = 0;
|
||||
setDetectionStyle(style);
|
||||
settleFrame(env);
|
||||
const strokes = echoStrokes();
|
||||
assert.ok(strokes.length > 0, style);
|
||||
assert.ok(
|
||||
strokes.every((stroke) => stroke === DETECTION_THEME_MAP[style].line),
|
||||
`${style} echoes take the style line colour, got ${strokes}`,
|
||||
);
|
||||
}
|
||||
|
||||
} finally {
|
||||
clearAwarenessEchoField();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a Global Context echo bracket is drawn outside its contact bracket and dimmer than it', () => {
|
||||
const env = installEnvironment();
|
||||
const echo = {
|
||||
layerIds: ['military'],
|
||||
fadeMs: 60_000,
|
||||
color: 'rgb(1, 2, 3)',
|
||||
spreadPx: 8,
|
||||
lineWidth: 3.25,
|
||||
};
|
||||
// Fronts every millisecond keep the echo at full strength; a level of 0.5
|
||||
// halves it, so it sits 4 px out and draws at half the bracket's alpha.
|
||||
try {
|
||||
setAwarenessEchoField({
|
||||
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
|
||||
radiusM: 250_000,
|
||||
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
|
||||
echo,
|
||||
level: () => 0.5,
|
||||
});
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [militaryLayer()], () => {});
|
||||
setMode('DENSE');
|
||||
setDetectionStyle('normal');
|
||||
settleFrame(env);
|
||||
env.detectionCtx.calls.length = 0;
|
||||
settleFrame(env);
|
||||
const strokes = env.detectionCtx.calls.filter(([name]) => name === 'stroke');
|
||||
const echoStroke = strokes.find(([, , style, width]) => style === echo.color && width === echo.lineWidth);
|
||||
assert.ok(echoStroke, 'the echo bracket is painted');
|
||||
const bracketStroke = strokes.find(([, path, style, width]) =>
|
||||
path instanceof MockPath2D && path.points.length && width !== echo.lineWidth && style !== echo.color);
|
||||
assert.ok(bracketStroke, 'the contact bracket is painted');
|
||||
const extent = (path) => {
|
||||
const xs = path.points.map(([x]) => x);
|
||||
const ys = path.points.map(([, y]) => y);
|
||||
return { halfW: (Math.max(...xs) - Math.min(...xs)) / 2, halfH: (Math.max(...ys) - Math.min(...ys)) / 2 };
|
||||
};
|
||||
const echoBox = extent(echoStroke[1]);
|
||||
const bracketBox = extent(bracketStroke[1]);
|
||||
assert.ok(Math.abs(echoBox.halfW - bracketBox.halfW - echo.spreadPx / 2) < 1e-3,
|
||||
`echo ${echoBox.halfW} vs bracket ${bracketBox.halfW}`);
|
||||
assert.ok(Math.abs(echoBox.halfH - bracketBox.halfH - echo.spreadPx / 2) < 1e-3);
|
||||
const echoAlpha = echoStroke[4];
|
||||
const bracketAlpha = bracketStroke[4];
|
||||
assert.ok(echoAlpha > 0 && echoAlpha < bracketAlpha, `${echoAlpha} vs ${bracketAlpha}`);
|
||||
assert.ok(Math.abs(echoAlpha - bracketAlpha / 2) < 1e-6, `${echoAlpha} vs ${bracketAlpha}`);
|
||||
} finally {
|
||||
clearAwarenessEchoField();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('under the Cyber sonar sweep an echo is never brighter than its own bracket', () => {
|
||||
const env = installEnvironment();
|
||||
// The cyber theme with its HUD open turns the contact sonar on; its sweep
|
||||
// dims brackets it is not passing over.
|
||||
env.document.documentElement = {
|
||||
// The lowest sweep floor, so dimmed brackets drop a whole alpha band.
|
||||
dataset: { uiTheme: 'cyber', cyberSonarOpacity: '35' },
|
||||
style: { setProperty() {}, removeProperty() {} },
|
||||
};
|
||||
const hud = env.document.createElement('div');
|
||||
hud.id = 'intel-hud';
|
||||
hud.classList = { contains: (name) => name === 'active' };
|
||||
env.document.body.appendChild(hud);
|
||||
const echo = {
|
||||
layerIds: ['military'],
|
||||
fadeMs: 60_000,
|
||||
color: 'rgb(1, 2, 3)',
|
||||
spreadPx: 8,
|
||||
lineWidth: 3.25,
|
||||
};
|
||||
const contact = {
|
||||
id: 'military',
|
||||
getDetectableObjects: () => [{
|
||||
position: new Cesium.Cartesian3(0.5, 0.3, 6_356_752),
|
||||
sourceId: 'sonar-pin',
|
||||
id: 'SONAR',
|
||||
metric: 'FL300',
|
||||
type: 'AIR',
|
||||
tier: 'military',
|
||||
}],
|
||||
};
|
||||
try {
|
||||
setAwarenessEchoField({
|
||||
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
|
||||
radiusM: 250_000,
|
||||
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
|
||||
echo,
|
||||
level: () => 1,
|
||||
});
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [contact], () => {});
|
||||
setMode('DENSE');
|
||||
setDetectionStyle('normal');
|
||||
settleFrame(env);
|
||||
let dimmed = 0;
|
||||
let compared = 0;
|
||||
for (let frame = 0; frame < 40; frame++) {
|
||||
env.detectionCtx.calls.length = 0;
|
||||
settleFrame(env);
|
||||
const strokes = env.detectionCtx.calls.filter(([name]) => name === 'stroke');
|
||||
const echoStroke = strokes.find(([, , style, width]) => style === echo.color && width === echo.lineWidth);
|
||||
const bracketStroke = strokes.find(([, path, style, width]) =>
|
||||
path instanceof MockPath2D && path.points.length && width !== echo.lineWidth && style !== echo.color);
|
||||
if (!bracketStroke) continue;
|
||||
const bracketAlpha = bracketStroke[4];
|
||||
if (bracketAlpha < 0.99) dimmed++;
|
||||
if (!echoStroke) continue;
|
||||
compared++;
|
||||
assert.ok(
|
||||
echoStroke[4] <= bracketAlpha + 1e-9,
|
||||
`echo ${echoStroke[4]} over bracket ${bracketAlpha} on frame ${frame}`,
|
||||
);
|
||||
}
|
||||
assert.ok(dimmed > 0, 'the sweep dimmed the bracket on some frame');
|
||||
assert.ok(compared > 0, 'an echo was painted');
|
||||
} finally {
|
||||
clearAwarenessEchoField();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('resting Global Context pulses skip echo work for the whole frame, not per contact', () => {
|
||||
const env = installEnvironment();
|
||||
// Six echo-eligible flights on screen; the identity camera maps x/y
|
||||
// straight to device coordinates.
|
||||
const flights = Array.from({ length: 6 }, (_, index) => ({
|
||||
position: new Cesium.Cartesian3(-0.5 + index * 0.2, 0, 6_356_752),
|
||||
sourceId: `flight-${index}`,
|
||||
id: `ECHO${index}`,
|
||||
metric: `FL${300 + index}`,
|
||||
type: 'AIR',
|
||||
}));
|
||||
const layer = { id: 'flights', getDetectableObjects: () => flights };
|
||||
let levelCalls = 0;
|
||||
try {
|
||||
setAwarenessEchoField({
|
||||
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
|
||||
radiusM: 250_000,
|
||||
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
|
||||
echo: {
|
||||
layerIds: ['flights'],
|
||||
fadeMs: 60_000,
|
||||
color: 'rgb(1, 2, 3)',
|
||||
spreadPx: 8,
|
||||
lineWidth: 3.25,
|
||||
},
|
||||
level: () => {
|
||||
levelCalls++;
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [layer], () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
levelCalls = 0;
|
||||
settleFrame(env);
|
||||
const painted = getDetectionDiagnostics().visibleCount;
|
||||
assert.equal(painted, flights.length, 'every flight is on screen');
|
||||
// One check per painted frame, however many contacts are on screen.
|
||||
assert.ok(levelCalls >= 1, 'the frame asks whether echoes are live');
|
||||
assert.ok(levelCalls < painted, `${levelCalls} level checks for ${painted} contacts`);
|
||||
assert.ok(
|
||||
!env.detectionCtx.calls.some(
|
||||
([name, , style]) => name === 'stroke' && style === 'rgb(1, 2, 3)',
|
||||
),
|
||||
'no echoes while the pulses rest',
|
||||
);
|
||||
} finally {
|
||||
clearAwarenessEchoField();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('pathological detection paint holds alternate frames without freezing shared lanes', () => {
|
||||
// Opts into the telemetry banner: the held-frame assertions count banner paints.
|
||||
const env = installEnvironment({ search: '?detectDebug=1' });
|
||||
|
||||
@@ -139,6 +139,7 @@ export function createFlowSegmentIndex(flowSegments) {
|
||||
roadType,
|
||||
level,
|
||||
closure,
|
||||
flow,
|
||||
};
|
||||
const key = cellKey(
|
||||
Math.floor((seg.ax + seg.bx) / 2 / CELL_SIZE_M),
|
||||
@@ -158,7 +159,8 @@ export function createFlowSegmentIndex(flowSegments) {
|
||||
const radius2 = MATCH_RADIUS_M * MATCH_RADIUS_M;
|
||||
/**
|
||||
* Nearest direction-compatible segment within 35 m of a projected point.
|
||||
* `radiusFor(segment)` may tighten the radius per segment (never widen it).
|
||||
* `radiusFor(segment)` may tighten the radius per segment (never widen it);
|
||||
* 0 leaves the segment out.
|
||||
*/
|
||||
function nearest(px, py, bearing, radiusFor = null) {
|
||||
const cx = Math.floor(px / CELL_SIZE_M);
|
||||
@@ -177,7 +179,7 @@ export function createFlowSegmentIndex(flowSegments) {
|
||||
candidate = true; // within radius, bearing not yet checked
|
||||
if (radiusFor) {
|
||||
const radius = Math.min(MATCH_RADIUS_M, radiusFor(seg));
|
||||
if (d2 > radius * radius) continue;
|
||||
if (!(radius > 0) || d2 > radius * radius) continue;
|
||||
}
|
||||
const diff = bearingDiffDeg(seg.bearing, bearing);
|
||||
if (
|
||||
|
||||
@@ -17,6 +17,7 @@ export const LAYER_FEED_STATES = Object.freeze([
|
||||
'loading',
|
||||
'degraded',
|
||||
'stale',
|
||||
'partial',
|
||||
'fallback',
|
||||
'unavailable',
|
||||
]);
|
||||
@@ -27,6 +28,7 @@ export const LAYER_FEED_STATE_LABELS = Object.freeze({
|
||||
loading: 'LOADING',
|
||||
degraded: 'DEGRADED',
|
||||
stale: 'STALE',
|
||||
partial: 'PARTIAL',
|
||||
fallback: 'FALLBACK',
|
||||
unavailable: 'UNAVAILABLE',
|
||||
});
|
||||
@@ -48,8 +50,9 @@ export const FEED_STATE_SEVERITY = Object.freeze({
|
||||
degraded: 2,
|
||||
stale: 3,
|
||||
fallback: 4,
|
||||
nominal: 5,
|
||||
off: 6,
|
||||
partial: 5,
|
||||
nominal: 6,
|
||||
off: 7,
|
||||
});
|
||||
|
||||
export { layerFeedState } from './feedState.js';
|
||||
@@ -90,7 +93,7 @@ export function snapshotAgeLabel(lastUpdate, now = Date.now()) {
|
||||
/**
|
||||
* Pick the most severe feed-state from a list.
|
||||
* @param {Iterable<string|null|undefined>} states Feed-state strings.
|
||||
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'|'off'|null}
|
||||
* @returns {'nominal'|'loading'|'degraded'|'stale'|'partial'|'fallback'|'unavailable'|'off'|null}
|
||||
*/
|
||||
export function worstFeedState(states) {
|
||||
let worst = null;
|
||||
@@ -205,6 +208,8 @@ export function feedProvenanceNote(layers = [], overall = null) {
|
||||
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
|
||||
});
|
||||
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
|
||||
if (overall === 'partial')
|
||||
return `${body}. Narrate that coverage is incomplete with any count; do not claim full coverage.`;
|
||||
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
|
||||
}
|
||||
|
||||
@@ -251,3 +256,63 @@ export function viewStateLayerRow(layer, options) {
|
||||
lastUpdate: snap.lastUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The feed state and up to two layer names of a provenance envelope that is
|
||||
* not nominal, or null; the worst layers come first, so the names match the
|
||||
* state. Values stay English (`state` is a feed-state id, `names` are
|
||||
* `layer.name`); the HUD translates them for display.
|
||||
* @param {{ overall?: string|null, layers?: Array<object> }|null} envelope
|
||||
* @returns {{ state: string, names: string[] }|null}
|
||||
*/
|
||||
export function provenanceTag(envelope) {
|
||||
const overall = envelope?.overall;
|
||||
if (!overall || overall === 'nominal' || overall === 'off') return null;
|
||||
const names = (envelope.layers || [])
|
||||
.filter(
|
||||
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
FEED_STATE_SEVERITY[a.feedState] - FEED_STATE_SEVERITY[b.feedState],
|
||||
)
|
||||
.map((s) => String(s.name || s.id || 'LAYER'))
|
||||
.slice(0, 2);
|
||||
return { state: overall, names };
|
||||
}
|
||||
|
||||
const upperCase = (word) => word.toUpperCase();
|
||||
|
||||
/**
|
||||
* `STATE | text | LAYERS`: the state leads so a narrow readout never clips
|
||||
* it; the layer names trail so they never push out the text.
|
||||
* @param {string} text
|
||||
* @param {{ state: string, names: string[] }|null} tag From `provenanceTag`.
|
||||
* @param {(state: string) => string} [stateLabel]
|
||||
* @param {(name: string) => string} [layerLabel]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function provenanceLine(
|
||||
text,
|
||||
tag,
|
||||
stateLabel = upperCase,
|
||||
layerLabel = upperCase,
|
||||
) {
|
||||
if (!tag) return text;
|
||||
return [stateLabel(tag.state), text, tag.names.map(layerLabel).join('/')]
|
||||
.filter(Boolean)
|
||||
.join(' | ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a matching feedState for warning states: a view that is not
|
||||
* nominal falls back on a missing or different state.
|
||||
* @param {{ feedState?: string|null }|null} response The proxy's reply.
|
||||
* @param {{ overall?: string|null }|null} provenance The context's envelope.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hudSummaryMatchesProvenance(response, provenance) {
|
||||
const state = provenance?.overall;
|
||||
if (!state || state === 'nominal' || state === 'off') return true;
|
||||
return response?.feedState === state;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
layerFeedState,
|
||||
layerSnapshot,
|
||||
layerSnapshots,
|
||||
provenanceTag,
|
||||
snapshotAgeLabel,
|
||||
snapshotAgeSec,
|
||||
viewStateLayerRow,
|
||||
@@ -90,7 +91,15 @@ test('layerFeedState matches the chip classifier for every honesty class', () =>
|
||||
test('chip labels and severity cover every feed-state the envelope can emit', () => {
|
||||
assert.deepEqual(
|
||||
[...LAYER_FEED_STATES],
|
||||
['nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable'],
|
||||
[
|
||||
'nominal',
|
||||
'loading',
|
||||
'degraded',
|
||||
'stale',
|
||||
'partial',
|
||||
'fallback',
|
||||
'unavailable',
|
||||
],
|
||||
);
|
||||
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
|
||||
for (const state of LAYER_FEED_STATES) {
|
||||
@@ -107,6 +116,33 @@ test('chip labels and severity cover every feed-state the envelope can emit', ()
|
||||
assert.equal(worstFeedState([]), null);
|
||||
});
|
||||
|
||||
test('partial is a feed-state of its own: above nominal, below every fault', () => {
|
||||
const layer = (id, stats) => ({ id, name: id, enabled: true, stats });
|
||||
const partial = layer('flights', {
|
||||
partial: true,
|
||||
count: 4,
|
||||
lastUpdate: NOW,
|
||||
});
|
||||
const nominal = layer('vessels', { count: 9, lastUpdate: NOW });
|
||||
const stale = layer('cctv', { stale: true, count: 2, lastUpdate: NOW });
|
||||
const overall = (layers) =>
|
||||
feedProvenanceEnvelope(layers, { now: NOW }).overall;
|
||||
assert.equal(overall([partial]), 'partial');
|
||||
assert.equal(overall([partial, nominal]), 'partial');
|
||||
assert.equal(overall([nominal, partial]), 'partial');
|
||||
assert.equal(overall([partial, stale]), 'stale');
|
||||
assert.equal(overall([stale, partial]), 'stale');
|
||||
assert.equal(LAYER_FEED_STATE_LABELS.partial, 'PARTIAL');
|
||||
const note = feedProvenanceEnvelope([partial], { now: NOW }).note;
|
||||
assert.match(note, /coverage is incomplete/);
|
||||
assert.doesNotMatch(note, /do not describe this as live/);
|
||||
assert.equal(
|
||||
FEED_STATE_SEVERITY.fallback < FEED_STATE_SEVERITY.partial &&
|
||||
FEED_STATE_SEVERITY.partial < FEED_STATE_SEVERITY.nominal,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('a disabled layer is off even when leftover stats would read stale', () => {
|
||||
const snap = layerSnapshot(
|
||||
{
|
||||
@@ -258,3 +294,18 @@ test('age labels match the Data Layers meta clock', () => {
|
||||
assert.equal(snapshotAgeLabel(NOW - 240_000, NOW), '4m ago');
|
||||
assert.equal(snapshotAgeLabel(NOW - 7_200_000, NOW), '2h ago');
|
||||
});
|
||||
|
||||
test('the warning tag names the layers in the reported state first', () => {
|
||||
const tag = provenanceTag({
|
||||
overall: 'unavailable',
|
||||
layers: [
|
||||
{ name: 'Live Flights', feedState: 'partial' },
|
||||
{ name: 'Live AIS Vessels', feedState: 'partial' },
|
||||
{ name: 'CCTV', feedState: 'unavailable' },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(tag, {
|
||||
state: 'unavailable',
|
||||
names: ['CCTV', 'Live Flights'],
|
||||
});
|
||||
});
|
||||
@@ -600,7 +600,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'drone-airspace',
|
||||
token: '6',
|
||||
token: '8',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
|
||||
@@ -671,6 +671,8 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'satellites',
|
||||
}),
|
||||
Object.freeze({ id: 'sea-marks', token: '6', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'ski-pistes', token: '7', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'street-level',
|
||||
token: '0',
|
||||
|
||||
@@ -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, 33);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 33);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 35);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 35);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
||||
@@ -242,20 +242,18 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
||||
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
||||
}
|
||||
assert.equal(nextLayerStateToken(), '7');
|
||||
assert.equal(nextLayerStateToken(), '9');
|
||||
assert.equal(
|
||||
nextLayerStateToken({
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
alpha: '6',
|
||||
bravo: '7',
|
||||
alpha: '9',
|
||||
bravo: '00',
|
||||
}),
|
||||
'8',
|
||||
'01',
|
||||
);
|
||||
const digitsExhausted = {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
...Object.fromEntries(
|
||||
[...'6789'].map((digit) => [`prior-${digit}`, digit]),
|
||||
),
|
||||
...Object.fromEntries([...'9'].map((digit) => [`prior-${digit}`, digit])),
|
||||
};
|
||||
assert.equal(nextLayerStateToken(digitsExhausted), '00');
|
||||
assert.equal(
|
||||
@@ -307,8 +305,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '7',
|
||||
next: '8',
|
||||
future: '9',
|
||||
next: '00',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -318,7 +316,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
}),
|
||||
/next free token 7/,
|
||||
/next free token 9/,
|
||||
);
|
||||
const beforeLastDigit = { ...digitsExhausted };
|
||||
delete beforeLastDigit['prior-9'];
|
||||
@@ -341,10 +339,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '7' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '7', competing: '7' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9', competing: '9' },
|
||||
),
|
||||
/next free token 8/,
|
||||
/next free token 00/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
|
||||
@@ -2696,15 +2694,15 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
|
||||
);
|
||||
});
|
||||
|
||||
test('drone restrictions uses digit 6 without colliding with fire perimeters', () => {
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=6.2'));
|
||||
test('drone restrictions uses digit 8 without colliding with fire perimeters', () => {
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=8.2'));
|
||||
assert.deepEqual(decoded.enabledLayerIds, [
|
||||
'drone-airspace',
|
||||
'fire-perimeters',
|
||||
]);
|
||||
assert.equal(
|
||||
LAYER_STATE_REGISTRY.find(({ id }) => id === 'drone-airspace').token,
|
||||
'6',
|
||||
'8',
|
||||
);
|
||||
assert.deepEqual(
|
||||
decodeLayerStateParams(new URLSearchParams(encode(decoded))),
|
||||
|
||||
@@ -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, 33);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 35);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{
|
||||
@@ -137,7 +137,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
'gnss-integrity': '3',
|
||||
'air-quality': '4',
|
||||
's2-ghostmaps': '5',
|
||||
'drone-airspace': '6',
|
||||
'sea-marks': '6',
|
||||
'ski-pistes': '7',
|
||||
'drone-airspace': '8',
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
@@ -163,9 +165,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
assert.equal(
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '7']])
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '9']])
|
||||
.future,
|
||||
'7',
|
||||
'9',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -183,14 +185,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', '7']] },
|
||||
{ rows: [...reservationRows, ['retired-layer', '9']] },
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '8');
|
||||
assert.equal(nextLayerStateToken(published), '00');
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
newcomer: '8',
|
||||
newcomer: '00',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -198,15 +200,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
competing: '7',
|
||||
competing: '9',
|
||||
}),
|
||||
/next free token 8/,
|
||||
/next free token 00/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
newcomer: '7',
|
||||
newcomer: '9',
|
||||
}),
|
||||
/changed or removed: retired-layer/,
|
||||
);
|
||||
@@ -214,8 +216,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
);
|
||||
});
|
||||
|
||||
test('PR B manually replaces provisional 7 with 8 after PR A publishes 7', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '7']] }, (cwd) => {
|
||||
test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '9']] }, (cwd) => {
|
||||
mkdirSync(path.join(cwd, 'scripts'));
|
||||
const fixtureChecker = path.join(
|
||||
cwd,
|
||||
@@ -236,8 +238,8 @@ test('PR B manually replaces provisional 7 with 8 after PR A publishes 7', () =>
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-b', '7']],
|
||||
[{ id: 'pr-b', token: '7' }],
|
||||
[...reservationRows, ['pr-b', '9']],
|
||||
[{ id: 'pr-b', token: '9' }],
|
||||
);
|
||||
const beforeRebase = runCheck();
|
||||
assert.equal(beforeRebase.status, 1);
|
||||
@@ -245,10 +247,10 @@ test('PR B manually replaces provisional 7 with 8 after PR A publishes 7', () =>
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '7'], ['pr-b', '7']],
|
||||
[...reservationRows, ['pr-a', '9'], ['pr-b', '9']],
|
||||
[
|
||||
{ id: 'pr-a', token: '7' },
|
||||
{ id: 'pr-b', token: '7' },
|
||||
{ id: 'pr-a', token: '9' },
|
||||
{ id: 'pr-b', token: '9' },
|
||||
],
|
||||
);
|
||||
const staleRebase = runCheck();
|
||||
@@ -256,16 +258,16 @@ test('PR B manually replaces provisional 7 with 8 after PR A publishes 7', () =>
|
||||
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
|
||||
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '8');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '7']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '7' }]);
|
||||
assert.equal(nextLayerStateToken(published), '00');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '9']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]);
|
||||
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: 8/);
|
||||
assert.match(helper.stdout, /pr-b: 00/);
|
||||
assert.deepEqual(
|
||||
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
||||
validBaselineRows,
|
||||
@@ -273,20 +275,20 @@ test('PR B manually replaces provisional 7 with 8 after PR A publishes 7', () =>
|
||||
);
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '7'], ['pr-b', '8']],
|
||||
[...reservationRows, ['pr-a', '9'], ['pr-b', '00']],
|
||||
[
|
||||
{ id: 'pr-a', token: '7' },
|
||||
{ id: 'pr-b', token: '8' },
|
||||
{ id: 'pr-a', token: '9' },
|
||||
{ id: 'pr-b', token: '00' },
|
||||
],
|
||||
);
|
||||
const corrected = runCheck();
|
||||
assert.equal(corrected.status, 0, corrected.stderr);
|
||||
assert.match(corrected.stdout, /34 published, 1 new/);
|
||||
assert.match(corrected.stdout, /36 published, 1 new/);
|
||||
});
|
||||
});
|
||||
|
||||
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
||||
const digitRows = [...'789'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const allDigits = parseLayerStateTokenReservations([
|
||||
...reservationRows,
|
||||
...digitRows,
|
||||
@@ -338,7 +340,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 = [...'789'].map((digit) => [
|
||||
const priorDigits = [...'9'].map((digit) => [
|
||||
`qa-prior-digit-${digit}`,
|
||||
digit,
|
||||
]);
|
||||
@@ -375,7 +377,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, 77);
|
||||
assert.equal(params.get('l').length, 81);
|
||||
const restored = codec.decodeLayerStateParams(params);
|
||||
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
|
||||
assert.deepEqual(restored?.options, state.options);
|
||||
@@ -393,7 +395,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, 34);
|
||||
assert.equal(Object.keys(published).length, 36);
|
||||
assert.equal(published['retired-layer'], '00');
|
||||
assert.throws(
|
||||
() =>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
["bikeshare", "b"],
|
||||
["cctv", "c"],
|
||||
["directions", "n"],
|
||||
["drone-airspace", "6"],
|
||||
["drone-airspace", "8"],
|
||||
["earthquakes", "e"],
|
||||
["fire-perimeters", "2"],
|
||||
["flights", "f"],
|
||||
@@ -23,6 +23,8 @@
|
||||
["rocket-launches", "x"],
|
||||
["s2-ghostmaps", "5"],
|
||||
["satellites", "s"],
|
||||
["sea-marks", "6"],
|
||||
["ski-pistes", "7"],
|
||||
["street-level", "0"],
|
||||
["telegeography-submarine-cables", "u"],
|
||||
["traffic", "t"],
|
||||
|
||||
@@ -189,7 +189,58 @@ const loadIndex = createRetryableLoader(async () => {
|
||||
return index;
|
||||
});
|
||||
|
||||
function toResult(entry, candidates = 1) {
|
||||
/** Same-name parts this close are one feature split by the pack. */
|
||||
const SAME_PLACE_GAP_KM = 100;
|
||||
|
||||
/** Gap between two bboxes in km, measured the short way across 180°. */
|
||||
function bboxGapKm(a, b) {
|
||||
const midLat = (a[1] + a[3] + b[1] + b[3]) / 4;
|
||||
const kmPerLon = 111.32 * Math.cos((midLat * Math.PI) / 180);
|
||||
let dLon = Infinity;
|
||||
for (const shift of [-360, 0, 360]) {
|
||||
dLon = Math.min(
|
||||
dLon,
|
||||
Math.max(0, Math.max(a[0], b[0] + shift) - Math.min(a[2], b[2] + shift)),
|
||||
);
|
||||
}
|
||||
const dLat = Math.max(0, Math.max(a[1], b[1]) - Math.min(a[3], b[3]));
|
||||
return Math.hypot(dLon * kmPerLon, dLat * 111.32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct places among same-name entries: parts that touch or nearly touch
|
||||
* (the Great Rift Valley's two halves, the Lau Group across 180°) merge into
|
||||
* one place with every part; distant namesakes (Flores in the Azores and in
|
||||
* Indonesia) stay separate. Largest place first.
|
||||
*/
|
||||
function groupPlaces(list) {
|
||||
const groups = [];
|
||||
for (const entry of list) {
|
||||
const near = groups.filter((group) =>
|
||||
group.some(
|
||||
(other) => bboxGapKm(other.bbox, entry.bbox) <= SAME_PLACE_GAP_KM,
|
||||
),
|
||||
);
|
||||
const merged = [entry, ...near.flat()];
|
||||
for (const group of near) groups.splice(groups.indexOf(group), 1);
|
||||
groups.push(merged);
|
||||
}
|
||||
return groups
|
||||
.map((group) => {
|
||||
const largest = group.reduce((a, b) => (b.areaKm2 > a.areaKm2 ? b : a));
|
||||
const [w, s, e, n] = largest.bbox;
|
||||
return {
|
||||
name: largest.name,
|
||||
featurecla: largest.featurecla,
|
||||
polygons: group.flatMap((entry) => entry.polygons),
|
||||
areaKm2: group.reduce((sum, entry) => sum + entry.areaKm2, 0),
|
||||
center: { lat: (s + n) / 2, lon: (w + e) / 2 },
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.areaKm2 - a.areaKm2);
|
||||
}
|
||||
|
||||
function toResult(entry, candidates = 1, list = [entry]) {
|
||||
return {
|
||||
name: entry.name,
|
||||
featurecla: entry.featurecla,
|
||||
@@ -198,6 +249,7 @@ function toResult(entry, candidates = 1) {
|
||||
bboxDiagonalKm: entry.bboxDiagonalKm,
|
||||
areaKm2: entry.areaKm2,
|
||||
candidates,
|
||||
places: groupPlaces(list),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,9 +262,12 @@ function toResult(entry, candidates = 1) {
|
||||
* @param {string} query e.g. "the Alps", "Rockies", "Gulf of Mexico"
|
||||
* @returns {Promise<{name:string, featurecla:string, kind:'natural'|'marine',
|
||||
* polygons:Array<Array<[number,number]>>, bboxDiagonalKm:number,
|
||||
* areaKm2:number, candidates:number}|null>} largest-area match plus the
|
||||
* number of same-name entries, or null when nothing matches. Uniqueness in
|
||||
* this pack alone does not establish the user's intended place.
|
||||
* areaKm2:number, candidates:number, places:Array<{name:string,
|
||||
* featurecla:string, polygons:Array, areaKm2:number,
|
||||
* center:{lat:number, lon:number}}>}|null>} largest-area match plus the
|
||||
* number of same-name entries, or null when nothing matches. `places`
|
||||
* groups those entries into distinct places, each with all its parts.
|
||||
* Uniqueness in this pack alone does not establish the user's intended place.
|
||||
*/
|
||||
export async function findNaturalRegion(query) {
|
||||
const norm = normalizeName(query);
|
||||
@@ -226,7 +281,7 @@ export async function findNaturalRegion(query) {
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const list = index.get(key);
|
||||
if (list && list.length) return toResult(list[0], list.length);
|
||||
if (list && list.length) return toResult(list[0], list.length, list);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -195,3 +195,21 @@ test('pointInRing: basic square', () => {
|
||||
assert.equal(pointInRing(sq, 15, 5), false);
|
||||
assert.equal(pointInRing([[0, 0], [1, 1]], 0.5, 0.5), false, 'degenerate ring');
|
||||
});
|
||||
|
||||
test('same-name parts of one feature are one place; distant namesakes are separate places', async () => {
|
||||
for (const name of ['Great Rift Valley', 'Lau Group', 'Transantarctic Mountains']) {
|
||||
const region = await findNaturalRegion(name);
|
||||
assert.equal(region?.places?.length, 1, `${name} is one place`);
|
||||
const place = region.places[0];
|
||||
assert.equal(place.polygons.length >= 2, true, `${name} keeps every part`);
|
||||
assert.ok(place.areaKm2 > region.areaKm2, `${name} counts more than its largest part`);
|
||||
}
|
||||
for (const name of ['Flores', 'Cordillera Oriental']) {
|
||||
const region = await findNaturalRegion(name);
|
||||
assert.equal(region?.places?.length, 2, `${name} names two places`);
|
||||
for (const place of region.places) {
|
||||
assert.ok(Number.isFinite(place.center.lat) && Number.isFinite(place.center.lon));
|
||||
}
|
||||
}
|
||||
assert.equal((await findNaturalRegion('Alps'))?.places?.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { NO_IMAGERY_HOST, resolveImageryHost } from '../maps/imageryHost.js';
|
||||
|
||||
/**
|
||||
* @file Open-data raster overlays: OpenSeaMap sea marks and OpenSnowMap ski
|
||||
* pistes (#10). Each overlay is its own layer with its own row under
|
||||
* Infrastructure; both are built from the descriptors below.
|
||||
*
|
||||
* OpenRailwayMap and OpenInfraMap, also named in #10, are deliberately not
|
||||
* included:
|
||||
* - OpenRailwayMap's public tile server (tiles.openrailwaymap.org) only
|
||||
* serves requests whose Referer is openrailwaymap.org; every other Referer,
|
||||
* including none, gets 403. A tab on this app sends its own origin, so the
|
||||
* tiles would fail, and embedding them would run against their tile-usage
|
||||
* policy either way.
|
||||
* - OpenInfraMap serves MapLibre vector tiles, not raster XYZ tiles. That
|
||||
* needs a vector-tile rendering pipeline, not an imagery-layer drop-in.
|
||||
*
|
||||
* The overlays drape on whatever surface the active map stack offers (see
|
||||
* `maps/imageryHost.js`): the globe on the OSM and Bing stacks, the Google
|
||||
* Photorealistic tileset's own `imageryLayers` on Google 3D. Only a stack with
|
||||
* neither leaves them hidden, and the row says so.
|
||||
* @module data/osmOverlays
|
||||
*/
|
||||
|
||||
/** @type {ReadonlyArray<{id:string,name:string,icon:string,source:string,urlTemplate:string,credit:string,maximumLevel:number}>} */
|
||||
export const OSM_OVERLAYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'sea-marks',
|
||||
name: 'Sea Marks',
|
||||
icon: '⚓',
|
||||
source: 'OpenSeaMap',
|
||||
urlTemplate: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
|
||||
// Chart tiles CC BY-SA 2.0; the underlying data is OpenStreetMap (ODbL).
|
||||
credit:
|
||||
'Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)',
|
||||
maximumLevel: 18,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'ski-pistes',
|
||||
name: 'Ski Pistes',
|
||||
icon: '⛷',
|
||||
source: 'OpenSnowMap',
|
||||
urlTemplate: 'https://www.opensnowmap.org/pistes/{z}/{x}/{y}.png',
|
||||
// OpenSnowMap asks for "(c)www.opensnowmap.org" wherever its tiles appear.
|
||||
credit:
|
||||
'Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)',
|
||||
maximumLevel: 18,
|
||||
}),
|
||||
]);
|
||||
|
||||
/** Why an enabled overlay is showing nothing, or null. */
|
||||
export function osmOverlayHiddenReason(enabled, hostKind) {
|
||||
return enabled && hostKind === 'none' ? NO_IMAGERY_HOST : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the layer for one overlay descriptor. Each application catalog gets
|
||||
* its own instances; the Cesium factories are injectable so tests run without
|
||||
* WebGL.
|
||||
*/
|
||||
export function createOsmOverlayLayer(
|
||||
overlay,
|
||||
{
|
||||
host = resolveImageryHost,
|
||||
createProvider = (descriptor) =>
|
||||
new Cesium.UrlTemplateImageryProvider({
|
||||
url: descriptor.urlTemplate,
|
||||
credit: descriptor.credit,
|
||||
maximumLevel: descriptor.maximumLevel,
|
||||
}),
|
||||
createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
|
||||
} = {},
|
||||
) {
|
||||
if (!OSM_OVERLAYS.includes(overlay))
|
||||
throw new TypeError(`Unknown open map overlay: ${overlay?.id}`);
|
||||
let viewer = null;
|
||||
let mapStack = null;
|
||||
let unsubscribeMap = null;
|
||||
let enabled = false;
|
||||
let hostInfo = { collection: null, kind: 'none' };
|
||||
/** The imagery layer this instance owns while enabled, and where it sits. */
|
||||
let owned = null;
|
||||
|
||||
const usable = (collection) => collection && !collection.isDestroyed?.();
|
||||
|
||||
function detach(destroy) {
|
||||
if (!owned) return;
|
||||
const { collection, layer } = owned;
|
||||
if (usable(collection) && collection.contains(layer))
|
||||
collection.remove(layer, destroy);
|
||||
else if (destroy && !layer.isDestroyed?.()) layer.destroy?.();
|
||||
owned.collection = null;
|
||||
if (destroy) owned = null;
|
||||
}
|
||||
|
||||
function resolveHost() {
|
||||
return (
|
||||
host({ viewer, tileset: mapStack?.getImageryHostTileset?.() }) || {
|
||||
collection: null,
|
||||
kind: 'none',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Create, move or drop the owned imagery layer to match the state. */
|
||||
function sync() {
|
||||
if (!viewer) return;
|
||||
hostInfo = enabled ? resolveHost() : { collection: null, kind: 'none' };
|
||||
if (!enabled) {
|
||||
detach(true);
|
||||
} else {
|
||||
owned ||= {
|
||||
layer: createImageryLayer(createProvider(overlay)),
|
||||
collection: null,
|
||||
};
|
||||
if (owned.collection !== hostInfo.collection) {
|
||||
detach(false);
|
||||
if (usable(hostInfo.collection)) {
|
||||
// Appended, so it draws above the map stack's base imagery.
|
||||
hostInfo.collection.add(owned.layer);
|
||||
owned.collection = hostInfo.collection;
|
||||
}
|
||||
}
|
||||
}
|
||||
viewer.scene?.requestRender?.();
|
||||
}
|
||||
|
||||
return {
|
||||
id: overlay.id,
|
||||
name: overlay.name,
|
||||
icon: overlay.icon,
|
||||
source: overlay.source,
|
||||
updateInterval: 0,
|
||||
init(nextViewer) {
|
||||
viewer = nextViewer;
|
||||
},
|
||||
/** Follow map-stack switches so the drape moves with the surface. */
|
||||
attachMapStackController(controller) {
|
||||
unsubscribeMap?.();
|
||||
unsubscribeMap = null;
|
||||
mapStack = controller || null;
|
||||
const off = controller?.subscribe?.(() => {
|
||||
if (enabled) sync();
|
||||
});
|
||||
unsubscribeMap = typeof off === 'function' ? off : null;
|
||||
},
|
||||
enable() {
|
||||
enabled = true;
|
||||
sync();
|
||||
},
|
||||
disable() {
|
||||
enabled = false;
|
||||
sync();
|
||||
},
|
||||
update() {
|
||||
return true;
|
||||
},
|
||||
getStats() {
|
||||
// Without a controller subscription the stats poll doubles as the
|
||||
// map-stack watch.
|
||||
if (enabled && !unsubscribeMap) {
|
||||
const next = resolveHost();
|
||||
if (next.collection !== hostInfo.collection) sync();
|
||||
}
|
||||
return {
|
||||
count: enabled ? 1 : 0,
|
||||
error: osmOverlayHiddenReason(enabled, hostInfo.kind),
|
||||
};
|
||||
},
|
||||
/** Owned layer count and host kind, for tests and live gates. */
|
||||
diagnostics() {
|
||||
return { host: hostInfo.kind, ownedCount: owned ? 1 : 0 };
|
||||
},
|
||||
destroy() {
|
||||
enabled = false;
|
||||
detach(true);
|
||||
unsubscribeMap?.();
|
||||
unsubscribeMap = null;
|
||||
mapStack = null;
|
||||
viewer = null;
|
||||
hostInfo = { collection: null, kind: 'none' };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** One layer per overlay, in panel order. */
|
||||
export function createOsmOverlayLayers(options) {
|
||||
return OSM_OVERLAYS.map((overlay) => createOsmOverlayLayer(overlay, options));
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
OSM_OVERLAYS,
|
||||
createOsmOverlayLayer,
|
||||
createOsmOverlayLayers,
|
||||
osmOverlayHiddenReason,
|
||||
} from './osmOverlays.js';
|
||||
import { NO_IMAGERY_HOST } from '../maps/imageryHost.js';
|
||||
|
||||
function fakeCollection() {
|
||||
const layers = [];
|
||||
const removed = [];
|
||||
return {
|
||||
layers,
|
||||
removed,
|
||||
add(layer) {
|
||||
layers.push(layer);
|
||||
},
|
||||
contains(layer) {
|
||||
return layers.includes(layer);
|
||||
},
|
||||
remove(layer, destroy) {
|
||||
const index = layers.indexOf(layer);
|
||||
if (index < 0) return false;
|
||||
layers.splice(index, 1);
|
||||
removed.push({ layer, destroy });
|
||||
if (destroy) layer.destroyed = true;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeMapStack() {
|
||||
const subscribers = new Set();
|
||||
return {
|
||||
tileset: null,
|
||||
getImageryHostTileset() {
|
||||
return this.tileset;
|
||||
},
|
||||
subscribe(fn) {
|
||||
subscribers.add(fn);
|
||||
return () => subscribers.delete(fn);
|
||||
},
|
||||
emit() {
|
||||
for (const fn of subscribers) fn();
|
||||
},
|
||||
subscribers,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(overlay = OSM_OVERLAYS[0]) {
|
||||
const globe = fakeCollection();
|
||||
const viewer = {
|
||||
imageryLayers: globe,
|
||||
scene: { globe: { show: true }, requestRender() {} },
|
||||
};
|
||||
const created = [];
|
||||
const layer = createOsmOverlayLayer(overlay, {
|
||||
createProvider: (descriptor) => ({ descriptor }),
|
||||
createImageryLayer: (provider) => {
|
||||
const imagery = { provider, destroyed: false };
|
||||
created.push(imagery);
|
||||
return imagery;
|
||||
},
|
||||
});
|
||||
const mapStack = fakeMapStack();
|
||||
layer.init(viewer);
|
||||
layer.attachMapStackController(mapStack);
|
||||
return { layer, viewer, globe, created, mapStack };
|
||||
}
|
||||
|
||||
test('OSM_OVERLAYS: the two shipped layers, each with a tile template and credit', () => {
|
||||
assert.deepEqual(
|
||||
OSM_OVERLAYS.map((o) => o.id),
|
||||
['sea-marks', 'ski-pistes'],
|
||||
);
|
||||
for (const overlay of OSM_OVERLAYS) {
|
||||
assert.match(overlay.urlTemplate, /^https:\/\/.*\{z\}.*\{x\}.*\{y\}/);
|
||||
assert.match(overlay.credit, /OpenStreetMap contributors \(ODbL\)/);
|
||||
}
|
||||
assert.match(OSM_OVERLAYS[0].credit, /CC BY-SA 2\.0/);
|
||||
assert.match(OSM_OVERLAYS[1].credit, /www\.opensnowmap\.org/);
|
||||
});
|
||||
|
||||
test('createOsmOverlayLayers builds one independent layer per overlay', () => {
|
||||
const layers = createOsmOverlayLayers();
|
||||
assert.deepEqual(
|
||||
layers.map((layer) => [layer.id, layer.name]),
|
||||
[
|
||||
['sea-marks', 'Sea Marks'],
|
||||
['ski-pistes', 'Ski Pistes'],
|
||||
],
|
||||
);
|
||||
assert.notEqual(createOsmOverlayLayers()[0], layers[0]);
|
||||
assert.throws(() => createOsmOverlayLayer({ id: 'other' }), /Unknown/);
|
||||
});
|
||||
|
||||
test('osmOverlayHiddenReason: only when enabled with no imagery host', () => {
|
||||
assert.equal(osmOverlayHiddenReason(false, 'none'), null);
|
||||
assert.equal(osmOverlayHiddenReason(true, 'globe'), null);
|
||||
assert.equal(osmOverlayHiddenReason(true, 'tileset'), null);
|
||||
assert.equal(osmOverlayHiddenReason(true, 'none'), NO_IMAGERY_HOST);
|
||||
});
|
||||
|
||||
test('a layer owns no imagery until enabled and destroys it on disable', () => {
|
||||
const { layer, globe, created } = setup(OSM_OVERLAYS[1]);
|
||||
assert.equal(created.length, 0);
|
||||
|
||||
layer.enable();
|
||||
assert.equal(globe.layers.length, 1);
|
||||
assert.equal(globe.layers[0].provider.descriptor.id, 'ski-pistes');
|
||||
assert.deepEqual(layer.getStats(), { count: 1, error: null });
|
||||
|
||||
layer.disable();
|
||||
assert.equal(globe.layers.length, 0);
|
||||
assert.equal(created[0].destroyed, true);
|
||||
assert.deepEqual(layer.diagnostics(), { host: 'none', ownedCount: 0 });
|
||||
assert.deepEqual(layer.getStats(), { count: 0, error: null });
|
||||
|
||||
layer.enable();
|
||||
assert.equal(created.length, 2, 're-enabling creates a fresh imagery layer');
|
||||
});
|
||||
|
||||
test('the overlay follows a map-stack switch onto the Google 3D tileset and back', () => {
|
||||
const { layer, viewer, globe, mapStack } = setup();
|
||||
layer.enable();
|
||||
const [imagery] = globe.layers;
|
||||
|
||||
const tileset = { imageryLayers: fakeCollection() };
|
||||
viewer.scene.globe.show = false;
|
||||
mapStack.tileset = tileset;
|
||||
mapStack.emit();
|
||||
assert.equal(globe.layers.length, 0);
|
||||
assert.deepEqual(tileset.imageryLayers.layers, [imagery]);
|
||||
assert.equal(globe.removed[0].destroy, false);
|
||||
assert.equal(layer.diagnostics().host, 'tileset');
|
||||
assert.equal(layer.getStats().error, null);
|
||||
|
||||
viewer.scene.globe.show = true;
|
||||
mapStack.tileset = null;
|
||||
mapStack.emit();
|
||||
assert.deepEqual(globe.layers, [imagery]);
|
||||
assert.equal(tileset.imageryLayers.layers.length, 0);
|
||||
});
|
||||
|
||||
test('with no surface to drape on the row explains itself, and recovers when one appears', () => {
|
||||
const { layer, viewer, globe, mapStack } = setup();
|
||||
viewer.scene.globe.show = false;
|
||||
layer.enable();
|
||||
assert.equal(globe.layers.length, 0);
|
||||
assert.equal(layer.getStats().error, NO_IMAGERY_HOST);
|
||||
|
||||
viewer.scene.globe.show = true;
|
||||
mapStack.emit();
|
||||
assert.equal(globe.layers.length, 1);
|
||||
assert.equal(layer.getStats().error, null);
|
||||
});
|
||||
|
||||
test('destroy removes owned imagery and unsubscribes from the map stack', () => {
|
||||
const { layer, globe, created, mapStack } = setup();
|
||||
layer.enable();
|
||||
layer.destroy();
|
||||
assert.equal(globe.layers.length, 0);
|
||||
assert.equal(created[0].destroyed, true);
|
||||
assert.equal(mapStack.subscribers.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* The OpenStreetMap feature kinds voice can search for ("hospitals",
|
||||
* "petrol stations", "police"), each with the exact tags it stands for.
|
||||
*
|
||||
* The server compiles Overpass queries only from these ids: a request names a
|
||||
* preset, never a tag, a regex or query text. Pure data; the browser and the
|
||||
* server both import it.
|
||||
*
|
||||
* @module data/osmPresets
|
||||
*/
|
||||
|
||||
/**
|
||||
* The noun is the model's data, not UI copy: it stays English.
|
||||
* id → { noun (plural, spoken), terms (other ways to say it), tags: [[key, value]] }.
|
||||
* A feature matches a preset when it carries ANY of the preset's tags.
|
||||
*/
|
||||
const PRESETS = {
|
||||
hospital: {
|
||||
noun: 'hospitals',
|
||||
terms: ['hospital', 'er', 'emergency room', 'medical center'],
|
||||
tags: [['amenity', 'hospital']],
|
||||
},
|
||||
clinic: {
|
||||
noun: 'clinics',
|
||||
terms: ['clinic', 'health center', 'health post', 'medical clinic'],
|
||||
tags: [['amenity', 'clinic']],
|
||||
},
|
||||
doctors: {
|
||||
noun: 'doctors',
|
||||
terms: ['doctor', "doctor's office", 'gp', 'physician'],
|
||||
tags: [['amenity', 'doctors']],
|
||||
},
|
||||
dentist: {
|
||||
noun: 'dentists',
|
||||
terms: ['dentist', 'dental clinic'],
|
||||
tags: [['amenity', 'dentist']],
|
||||
},
|
||||
pharmacy: {
|
||||
noun: 'pharmacies',
|
||||
terms: ['pharmacy', 'chemist', 'drugstore', 'drug store'],
|
||||
tags: [['amenity', 'pharmacy']],
|
||||
},
|
||||
school: {
|
||||
noun: 'schools',
|
||||
terms: ['school', 'primary school', 'secondary school', 'high school'],
|
||||
tags: [['amenity', 'school']],
|
||||
},
|
||||
kindergarten: {
|
||||
noun: 'kindergartens',
|
||||
terms: ['kindergarten', 'preschool', 'nursery school'],
|
||||
tags: [['amenity', 'kindergarten']],
|
||||
},
|
||||
university: {
|
||||
noun: 'universities',
|
||||
terms: ['university', 'campus'],
|
||||
tags: [['amenity', 'university']],
|
||||
},
|
||||
college: {
|
||||
noun: 'colleges',
|
||||
terms: ['college'],
|
||||
tags: [['amenity', 'college']],
|
||||
},
|
||||
library: {
|
||||
noun: 'libraries',
|
||||
terms: ['library'],
|
||||
tags: [['amenity', 'library']],
|
||||
},
|
||||
police: {
|
||||
noun: 'police stations',
|
||||
terms: ['police', 'police station', 'police post'],
|
||||
tags: [['amenity', 'police']],
|
||||
},
|
||||
fire_station: {
|
||||
noun: 'fire stations',
|
||||
terms: ['fire station', 'fire department', 'firehouse'],
|
||||
tags: [['amenity', 'fire_station']],
|
||||
},
|
||||
prison: {
|
||||
noun: 'prisons',
|
||||
terms: ['prison', 'jail', 'correctional facility'],
|
||||
tags: [['amenity', 'prison']],
|
||||
},
|
||||
courthouse: {
|
||||
noun: 'courthouses',
|
||||
terms: ['courthouse', 'court'],
|
||||
tags: [['amenity', 'courthouse']],
|
||||
},
|
||||
townhall: {
|
||||
noun: 'town halls',
|
||||
terms: ['town hall', 'city hall'],
|
||||
tags: [['amenity', 'townhall']],
|
||||
},
|
||||
embassy: {
|
||||
noun: 'embassies',
|
||||
terms: ['embassy', 'consulate'],
|
||||
tags: [
|
||||
['office', 'diplomatic'],
|
||||
['amenity', 'embassy'],
|
||||
],
|
||||
},
|
||||
post_office: {
|
||||
noun: 'post offices',
|
||||
terms: ['post office'],
|
||||
tags: [['amenity', 'post_office']],
|
||||
},
|
||||
bank: { noun: 'banks', terms: ['bank'], tags: [['amenity', 'bank']] },
|
||||
atm: {
|
||||
noun: 'ATMs',
|
||||
terms: ['atm', 'cash machine', 'cashpoint'],
|
||||
tags: [['amenity', 'atm']],
|
||||
},
|
||||
fuel: {
|
||||
noun: 'fuel stations',
|
||||
terms: [
|
||||
'gas station',
|
||||
'petrol station',
|
||||
'fuel station',
|
||||
'filling station',
|
||||
'gas',
|
||||
],
|
||||
tags: [['amenity', 'fuel']],
|
||||
},
|
||||
charging_station: {
|
||||
noun: 'EV charging stations',
|
||||
terms: ['charging station', 'ev charger', 'ev charging', 'car charger'],
|
||||
tags: [['amenity', 'charging_station']],
|
||||
},
|
||||
parking: {
|
||||
noun: 'car parks',
|
||||
terms: ['parking', 'parking lot', 'car park', 'parking garage'],
|
||||
tags: [['amenity', 'parking']],
|
||||
},
|
||||
restaurant: {
|
||||
noun: 'restaurants',
|
||||
terms: ['restaurant'],
|
||||
tags: [['amenity', 'restaurant']],
|
||||
},
|
||||
cafe: {
|
||||
noun: 'cafés',
|
||||
terms: ['cafe', 'café', 'coffee shop', 'coffee'],
|
||||
tags: [['amenity', 'cafe']],
|
||||
},
|
||||
fast_food: {
|
||||
noun: 'fast-food places',
|
||||
terms: ['fast food', 'fast-food'],
|
||||
tags: [['amenity', 'fast_food']],
|
||||
},
|
||||
bar: {
|
||||
noun: 'bars',
|
||||
terms: ['bar', 'pub'],
|
||||
tags: [
|
||||
['amenity', 'bar'],
|
||||
['amenity', 'pub'],
|
||||
],
|
||||
},
|
||||
place_of_worship: {
|
||||
noun: 'places of worship',
|
||||
terms: [
|
||||
'place of worship',
|
||||
'church',
|
||||
'mosque',
|
||||
'temple',
|
||||
'synagogue',
|
||||
'gurdwara',
|
||||
],
|
||||
tags: [['amenity', 'place_of_worship']],
|
||||
},
|
||||
marketplace: {
|
||||
noun: 'markets',
|
||||
terms: ['market', 'marketplace', 'bazaar'],
|
||||
tags: [['amenity', 'marketplace']],
|
||||
},
|
||||
supermarket: {
|
||||
noun: 'supermarkets',
|
||||
terms: ['supermarket', 'grocery store', 'grocery'],
|
||||
tags: [['shop', 'supermarket']],
|
||||
},
|
||||
mall: {
|
||||
noun: 'shopping malls',
|
||||
terms: ['mall', 'shopping mall', 'shopping center', 'shopping centre'],
|
||||
tags: [['shop', 'mall']],
|
||||
},
|
||||
convenience: {
|
||||
noun: 'convenience stores',
|
||||
terms: ['convenience store', 'corner shop'],
|
||||
tags: [['shop', 'convenience']],
|
||||
},
|
||||
hotel: { noun: 'hotels', terms: ['hotel'], tags: [['tourism', 'hotel']] },
|
||||
hostel: {
|
||||
noun: 'hostels',
|
||||
terms: ['hostel'],
|
||||
tags: [['tourism', 'hostel']],
|
||||
},
|
||||
museum: {
|
||||
noun: 'museums',
|
||||
terms: ['museum'],
|
||||
tags: [['tourism', 'museum']],
|
||||
},
|
||||
attraction: {
|
||||
noun: 'tourist attractions',
|
||||
terms: ['tourist attraction', 'attraction', 'sight', 'sights'],
|
||||
tags: [['tourism', 'attraction']],
|
||||
},
|
||||
viewpoint: {
|
||||
noun: 'viewpoints',
|
||||
terms: ['viewpoint', 'lookout', 'scenic view'],
|
||||
tags: [['tourism', 'viewpoint']],
|
||||
},
|
||||
zoo: { noun: 'zoos', terms: ['zoo'], tags: [['tourism', 'zoo']] },
|
||||
cinema: {
|
||||
noun: 'cinemas',
|
||||
terms: ['cinema', 'movie theater', 'movie theatre'],
|
||||
tags: [['amenity', 'cinema']],
|
||||
},
|
||||
theatre: {
|
||||
noun: 'theatres',
|
||||
terms: ['theatre', 'theater', 'playhouse'],
|
||||
tags: [['amenity', 'theatre']],
|
||||
},
|
||||
stadium: {
|
||||
noun: 'stadiums',
|
||||
terms: ['stadium', 'arena'],
|
||||
tags: [['leisure', 'stadium']],
|
||||
},
|
||||
park: { noun: 'parks', terms: ['park'], tags: [['leisure', 'park']] },
|
||||
playground: {
|
||||
noun: 'playgrounds',
|
||||
terms: ['playground'],
|
||||
tags: [['leisure', 'playground']],
|
||||
},
|
||||
sports_centre: {
|
||||
noun: 'sports centres',
|
||||
terms: ['sports center', 'sports centre', 'gym', 'leisure center'],
|
||||
tags: [
|
||||
['leisure', 'sports_centre'],
|
||||
['leisure', 'fitness_centre'],
|
||||
],
|
||||
},
|
||||
cemetery: {
|
||||
noun: 'cemeteries',
|
||||
terms: ['cemetery', 'graveyard'],
|
||||
tags: [
|
||||
['landuse', 'cemetery'],
|
||||
['amenity', 'grave_yard'],
|
||||
],
|
||||
},
|
||||
toilets: {
|
||||
noun: 'public toilets',
|
||||
terms: ['toilet', 'toilets', 'restroom', 'public toilet', 'bathroom'],
|
||||
tags: [['amenity', 'toilets']],
|
||||
},
|
||||
drinking_water: {
|
||||
noun: 'drinking water points',
|
||||
terms: ['drinking water', 'water fountain', 'water point'],
|
||||
tags: [['amenity', 'drinking_water']],
|
||||
},
|
||||
shelter: {
|
||||
noun: 'shelters',
|
||||
terms: ['shelter', 'emergency shelter'],
|
||||
tags: [['amenity', 'shelter']],
|
||||
},
|
||||
airport: {
|
||||
noun: 'airports',
|
||||
terms: ['airport', 'airfield', 'aerodrome', 'airstrip'],
|
||||
tags: [['aeroway', 'aerodrome']],
|
||||
},
|
||||
helipad: {
|
||||
noun: 'helipads',
|
||||
terms: ['helipad', 'heliport'],
|
||||
tags: [
|
||||
['aeroway', 'helipad'],
|
||||
['aeroway', 'heliport'],
|
||||
],
|
||||
},
|
||||
train_station: {
|
||||
noun: 'train stations',
|
||||
terms: ['train station', 'railway station', 'rail station'],
|
||||
tags: [['railway', 'station']],
|
||||
},
|
||||
bus_station: {
|
||||
noun: 'bus stations',
|
||||
terms: ['bus station', 'bus terminal', 'bus depot'],
|
||||
tags: [['amenity', 'bus_station']],
|
||||
},
|
||||
ferry_terminal: {
|
||||
noun: 'ferry terminals',
|
||||
terms: ['ferry terminal', 'ferry'],
|
||||
tags: [['amenity', 'ferry_terminal']],
|
||||
},
|
||||
harbour: {
|
||||
noun: 'harbours',
|
||||
terms: ['harbour', 'harbor', 'port', 'marina'],
|
||||
tags: [
|
||||
['harbour', 'yes'],
|
||||
['leisure', 'marina'],
|
||||
['landuse', 'port'],
|
||||
],
|
||||
},
|
||||
bridge: {
|
||||
noun: 'bridges',
|
||||
terms: ['bridge'],
|
||||
tags: [['man_made', 'bridge']],
|
||||
},
|
||||
lighthouse: {
|
||||
noun: 'lighthouses',
|
||||
terms: ['lighthouse'],
|
||||
tags: [['man_made', 'lighthouse']],
|
||||
},
|
||||
water_tower: {
|
||||
noun: 'water towers',
|
||||
terms: ['water tower'],
|
||||
tags: [['man_made', 'water_tower']],
|
||||
},
|
||||
power_plant: {
|
||||
noun: 'power plants',
|
||||
terms: ['power plant', 'power station', 'generating station'],
|
||||
tags: [['power', 'plant']],
|
||||
},
|
||||
substation: {
|
||||
noun: 'substations',
|
||||
terms: ['substation', 'electrical substation'],
|
||||
tags: [['power', 'substation']],
|
||||
},
|
||||
wind_turbine: {
|
||||
noun: 'wind turbines',
|
||||
terms: ['wind turbine', 'windmill'],
|
||||
tags: [['generator:source', 'wind']],
|
||||
},
|
||||
dam: { noun: 'dams', terms: ['dam'], tags: [['waterway', 'dam']] },
|
||||
military: {
|
||||
noun: 'military sites',
|
||||
terms: ['military base', 'military site', 'base', 'barracks'],
|
||||
tags: [
|
||||
['landuse', 'military'],
|
||||
['military', 'base'],
|
||||
['military', 'barracks'],
|
||||
],
|
||||
},
|
||||
peak: {
|
||||
noun: 'peaks',
|
||||
terms: ['peak', 'mountain peak', 'summit', 'mountain'],
|
||||
tags: [['natural', 'peak']],
|
||||
},
|
||||
waterfall: {
|
||||
noun: 'waterfalls',
|
||||
terms: ['waterfall', 'falls'],
|
||||
tags: [['waterway', 'waterfall']],
|
||||
},
|
||||
};
|
||||
|
||||
/** Frozen preset table. */
|
||||
export const OSM_PRESETS = Object.freeze(
|
||||
Object.fromEntries(
|
||||
Object.entries(PRESETS).map(([id, preset]) => [
|
||||
id,
|
||||
Object.freeze({
|
||||
id,
|
||||
label: preset.noun,
|
||||
terms: Object.freeze([...preset.terms]),
|
||||
tags: Object.freeze(
|
||||
preset.tags.map((pair) => Object.freeze([...pair])),
|
||||
),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
/** Every preset id, for tool enums. */
|
||||
export const OSM_PRESET_IDS = Object.freeze(Object.keys(OSM_PRESETS));
|
||||
|
||||
const normalize = (text) =>
|
||||
String(text || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
.replace(/[^a-z0-9 ]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/** Naive singular: "pharmacies" → "pharmacy", "hospitals" → "hospital". */
|
||||
function singular(word) {
|
||||
if (word.endsWith('ies')) return `${word.slice(0, -3)}y`;
|
||||
if (/(ches|shes|sses|xes)$/.test(word)) return word.slice(0, -2);
|
||||
if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1);
|
||||
return word;
|
||||
}
|
||||
const singularPhrase = (text) => text.split(' ').map(singular).join(' ');
|
||||
|
||||
const TERM_INDEX = (() => {
|
||||
const index = new Map();
|
||||
for (const preset of Object.values(OSM_PRESETS)) {
|
||||
for (const term of [
|
||||
preset.id.replace(/_/g, ' '),
|
||||
singularPhrase(normalize(preset.label)),
|
||||
...preset.terms,
|
||||
]) {
|
||||
const key = singularPhrase(normalize(term));
|
||||
if (key && !index.has(key)) index.set(key, preset.id);
|
||||
}
|
||||
}
|
||||
return index;
|
||||
})();
|
||||
|
||||
/**
|
||||
* The preset a spoken phrase names ("all the hospitals" → hospital), or null.
|
||||
* An exact preset id wins; otherwise the longest known term found in the
|
||||
* phrase.
|
||||
* @param {string} phrase
|
||||
* @returns {object|null} A preset.
|
||||
*/
|
||||
export function matchOsmPreset(phrase) {
|
||||
const raw = String(phrase || '').trim();
|
||||
if (Object.hasOwn(OSM_PRESETS, raw)) return OSM_PRESETS[raw];
|
||||
const text = singularPhrase(
|
||||
normalize(raw).replace(/^(all|every|the|any)( the)? /, ''),
|
||||
);
|
||||
if (!text) return null;
|
||||
if (TERM_INDEX.has(text)) return OSM_PRESETS[TERM_INDEX.get(text)];
|
||||
let best = null;
|
||||
for (const [term, id] of TERM_INDEX) {
|
||||
if (term.length < 3) continue;
|
||||
if (!new RegExp(`(^| )${term}( |$)`).test(text)) continue;
|
||||
if (!best || term.length > best.term.length) best = { term, id };
|
||||
}
|
||||
return best ? OSM_PRESETS[best.id] : null;
|
||||
}
|
||||
+16
-75
@@ -51,6 +51,7 @@ import {
|
||||
radioViewIsGlobal,
|
||||
stationMatchesRadioCategory,
|
||||
} from './radio.js';
|
||||
import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs';
|
||||
|
||||
const stations = [
|
||||
{ id: 'news', tags: ['News', 'Weather Radio', 'air traffic'] },
|
||||
@@ -883,88 +884,33 @@ test('tuner drag keeps one immutable catalog resolution through refresh and rele
|
||||
});
|
||||
|
||||
test('failed exact tuner release cannot consume a stale playback fallback', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalAudio = globalThis.Audio;
|
||||
const playUrls = [];
|
||||
const stationRows = [
|
||||
{
|
||||
radioStationRow({
|
||||
id: '00000000-0000-4000-8000-000000000071',
|
||||
name: 'Exact target',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
streamUrl: 'https://radio.example.com/exact.mp3',
|
||||
homepage: null,
|
||||
tags: ['news'],
|
||||
languages: ['English'],
|
||||
state: 'Texas',
|
||||
country: 'United States',
|
||||
countryCode: 'US',
|
||||
metadataTrust: 'untrusted-community',
|
||||
codec: 'MP3',
|
||||
bitrate: 128,
|
||||
},
|
||||
{
|
||||
}),
|
||||
radioStationRow({
|
||||
id: '00000000-0000-4000-8000-000000000072',
|
||||
name: 'Stale fallback',
|
||||
lat: 31,
|
||||
lon: -98,
|
||||
streamUrl: 'https://radio.example.com/fallback.mp3',
|
||||
homepage: null,
|
||||
tags: ['news'],
|
||||
languages: ['English'],
|
||||
state: 'Texas',
|
||||
country: 'United States',
|
||||
countryCode: 'US',
|
||||
metadataTrust: 'untrusted-community',
|
||||
codec: 'MP3',
|
||||
bitrate: 128,
|
||||
},
|
||||
}),
|
||||
];
|
||||
globalThis.Audio = class FakeAudio {
|
||||
constructor() {
|
||||
this.volume = 0.8;
|
||||
this.src = '';
|
||||
}
|
||||
|
||||
addEventListener() {}
|
||||
pause() {}
|
||||
play() {
|
||||
playUrls.push(this.src);
|
||||
if (this.src === stationRows[0].streamUrl) {
|
||||
return Promise.reject(new DOMException('Exact target unavailable', 'NotSupportedError'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
removeAttribute() { this.src = ''; }
|
||||
load() {}
|
||||
};
|
||||
globalThis.fetch = async (url) => String(url).startsWith('/api/radio/click/')
|
||||
? { ok: true }
|
||||
: new Response(JSON.stringify({
|
||||
stations: stationRows,
|
||||
stale: false,
|
||||
degraded: false,
|
||||
acceptedGeneration: 1,
|
||||
catalogInstance: 'qa-instance-a',
|
||||
updatedAt: new Date().toISOString(),
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
const viewer = {
|
||||
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
|
||||
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
|
||||
dataSources: { add() {}, remove() {} },
|
||||
entities: { add(entity) { return entity; }, remove() {} },
|
||||
};
|
||||
|
||||
radioLayer.destroy();
|
||||
try {
|
||||
radioLayer.init(viewer);
|
||||
radioLayer.enable();
|
||||
await radioLayer.update();
|
||||
assert.equal(radioLayer.selectStation(stationRows[0].id, { autoplay: false }), true);
|
||||
await withRadioLayer({
|
||||
stations: stationRows,
|
||||
failingUrls: [stationRows[0].streamUrl],
|
||||
}, async ({ playUrls }) => {
|
||||
// A playing stream, so the release plays.
|
||||
assert.equal(radioLayer.selectStation(stationRows[1].id, { autoplay: true }), true);
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
assert.equal(radioLayer.cycleStation(1, {
|
||||
stationIds: stationRows.map(({ id }) => id),
|
||||
autoplay: false,
|
||||
}), true, 'non-playing cycle creates the stale-fallback precondition');
|
||||
fallback: true,
|
||||
}), true, 'a voice-style prepared cycle creates the stale-fallback precondition');
|
||||
playUrls.length = 0;
|
||||
assert.equal(radioLayer.beginTuning(), true);
|
||||
assert.equal(radioLayer.previewTuningStation(stationRows[0].id, { rotate: false }), true);
|
||||
assert.equal(radioLayer.commitTuningStation(stationRows[0].id, { origin: 'user' }).ok, true);
|
||||
@@ -975,12 +921,7 @@ test('failed exact tuner release cannot consume a stale playback fallback', asyn
|
||||
assert.equal(state.audioState, 'error');
|
||||
assert.equal(state.tuningStatic, true);
|
||||
assert.equal(state.tuningAwaitingStationId, stationRows[0].id);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalAudio === undefined) delete globalThis.Audio;
|
||||
else globalThis.Audio = originalAudio;
|
||||
radioLayer.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('tuner cancellation restores the frozen start marker after catalog removal', async () => {
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
// Radio selection, fallbacks and voice handoffs on the real layer and its published view.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { radioLayer } from './radio.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { renderRadioState } from '../ui/radioPresentation.js';
|
||||
import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs';
|
||||
|
||||
const READY = 'Ready — playback starts only from your action';
|
||||
const ids = [1, 2, 3].map((n) => `00000000-0000-4000-8000-00000000009${n}`);
|
||||
const stations = ids.map((id, index) => radioStationRow({
|
||||
id,
|
||||
name: `Station ${'ABC'[index]}`,
|
||||
lat: 30 + index,
|
||||
lon: -97 - index,
|
||||
streamUrl: `https://radio.example.com/${'abc'[index]}.mp3`,
|
||||
}));
|
||||
const withLayer = (failingUrls, body) => withRadioLayer({ stations, failingUrls }, body);
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
// What the Radio panel and the Cockpit player show for a state.
|
||||
function render(state) {
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = { getElementById: () => null, activeElement: null };
|
||||
try {
|
||||
let view = null;
|
||||
const host = {
|
||||
destroyed: false,
|
||||
_radioPanel: {},
|
||||
store: { publish: (published) => (view = published), publishTuner() {} },
|
||||
actions: {
|
||||
getLifecycle: () => null,
|
||||
preservePanelStateDuringClear: () => false,
|
||||
setPanelCollapsed() {},
|
||||
scheduleLayout() {},
|
||||
},
|
||||
};
|
||||
renderRadioState.call(host, state);
|
||||
return {
|
||||
status: view.playback.text,
|
||||
error: Boolean(view.playback.error),
|
||||
button: view.transport.play.text,
|
||||
stopDisabled: view.transport.stopDisabled,
|
||||
cockpit: view.cockpit.play.glyph,
|
||||
};
|
||||
} finally {
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = previousDocument;
|
||||
}
|
||||
}
|
||||
|
||||
const readyToPlay = { status: READY, error: false, button: 'PLAY', stopDisabled: true, cockpit: '▶' };
|
||||
|
||||
test('Next before Play only selects, and Play tries just that station', async () => {
|
||||
await withLayer([stations[1].streamUrl], async ({ playUrls }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||
assert.equal(radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }), true);
|
||||
await settle();
|
||||
assert.deepEqual(playUrls, []);
|
||||
assert.equal(radioLayer.getUIState().audioState, 'stopped');
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
await settle();
|
||||
const state = radioLayer.getUIState();
|
||||
assert.deepEqual(playUrls, [stations[1].streamUrl], 'no fallback picked by the silent step');
|
||||
assert.equal(state.selected?.id, ids[1]);
|
||||
assert.equal(state.audioState, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
test('Next after a failed stream leaves the new station ready to play, without the old error', async () => {
|
||||
setLocale('en');
|
||||
await withLayer([stations[0].streamUrl], async ({ playUrls }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
|
||||
await settle();
|
||||
assert.equal(radioLayer.getUIState().audioState, 'error');
|
||||
assert.equal(radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }), true);
|
||||
const state = radioLayer.getUIState();
|
||||
assert.equal(state.selected?.id, ids[1]);
|
||||
assert.equal(state.audioState, 'stopped');
|
||||
assert.equal(state.playingStationId, null);
|
||||
assert.deepEqual(render(state), readyToPlay);
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
assert.deepEqual(playUrls, [stations[0].streamUrl, stations[1].streamUrl]);
|
||||
assert.equal(radioLayer.getUIState().playingStationId, ids[1]);
|
||||
});
|
||||
});
|
||||
|
||||
test('Next while paused releases the paused stream, and Play starts the new station', async () => {
|
||||
setLocale('en');
|
||||
await withLayer([], async ({ playUrls }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
|
||||
await settle();
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
assert.equal(radioLayer.getUIState().audioState, 'paused');
|
||||
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' });
|
||||
const state = radioLayer.getUIState();
|
||||
assert.equal(state.selected?.id, ids[1]);
|
||||
assert.equal(state.audioState, 'stopped');
|
||||
assert.equal(state.playingStationId, null);
|
||||
assert.deepEqual(render(state), readyToPlay);
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
assert.deepEqual(playUrls, [stations[0].streamUrl, stations[1].streamUrl]);
|
||||
assert.equal(radioLayer.getUIState().audioState, 'playing');
|
||||
assert.equal(radioLayer.getUIState().playingStationId, ids[1]);
|
||||
});
|
||||
});
|
||||
|
||||
test('a catalog refresh after a paused Next cannot bring back Resume for an unnamed station', async () => {
|
||||
setLocale('en');
|
||||
await withLayer([], async ({ catalog }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
|
||||
await settle();
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' });
|
||||
catalog.rows = stations.filter(({ id }) => id !== ids[1]);
|
||||
catalog.generation = 2;
|
||||
await radioLayer.update();
|
||||
const state = radioLayer.getUIState();
|
||||
assert.equal(state.selected, null);
|
||||
assert.equal(state.audioState, 'stopped');
|
||||
const shown = render(state);
|
||||
assert.equal(shown.status, READY);
|
||||
assert.equal(shown.button, 'PLAY');
|
||||
});
|
||||
});
|
||||
|
||||
test('a voice station request after a voice pause keeps the fallback of that request', async () => {
|
||||
const failing = [];
|
||||
await withLayer(failing, async ({ playUrls }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
|
||||
await settle();
|
||||
radioLayer.pause({ origin: 'voice-duck' });
|
||||
playUrls.length = 0;
|
||||
// Anchor on the far station so the paused one is not the first match.
|
||||
const first = radioLayer.selectRequestedStation(
|
||||
{ categoryId: 'all', anchor: { lat: 32, lon: -99 } },
|
||||
{ autoplay: false },
|
||||
);
|
||||
failing.push(first.streamUrl);
|
||||
await radioLayer.play({ origin: 'voice' });
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(playUrls.length, 2);
|
||||
assert.equal(playUrls[0], first.streamUrl);
|
||||
assert.equal(radioLayer.getUIState().audioState, 'playing');
|
||||
});
|
||||
});
|
||||
|
||||
test('a cancelled voice step leaves no fallback for a later Play from the panel', async () => {
|
||||
await withLayer([stations[1].streamUrl], async ({ playUrls }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
|
||||
// The handoff is cancelled (barge-in), so voice never starts playback.
|
||||
await settle();
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
await settle();
|
||||
await settle();
|
||||
const state = radioLayer.getUIState();
|
||||
assert.deepEqual(playUrls, [stations[1].streamUrl]);
|
||||
assert.equal(state.selected?.id, ids[1]);
|
||||
assert.equal(state.audioState, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
test('only a user step to another station tells voice to drop its prepared playback', async () => {
|
||||
await withLayer([], async () => {
|
||||
const controls = [];
|
||||
const unsubscribe = radioLayer.subscribePlaybackControls((event) => controls.push(event));
|
||||
try {
|
||||
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
|
||||
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' });
|
||||
radioLayer.cycleStation(1, { stationIds: [ids[1]], autoplay: false, origin: 'user' });
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
assert.equal(radioLayer.getUIState().selected?.id, ids[1]);
|
||||
assert.deepEqual(controls.map(({ action, origin }) => [action, origin]), [['select', 'user']]);
|
||||
});
|
||||
});
|
||||
|
||||
test('an unavailable tuner release stops the paused stream once, and Play still falls back', async () => {
|
||||
setLocale('en');
|
||||
const failing = [];
|
||||
await withLayer(failing, async ({ playUrls, catalog }) => {
|
||||
radioLayer.selectStation(ids[2], { autoplay: true, origin: 'user' });
|
||||
await settle();
|
||||
assert.equal(radioLayer.beginTuning(), true);
|
||||
radioLayer.previewTuningStation(ids[1], { rotate: false });
|
||||
// A directory refresh changes the station under the needle mid-drag.
|
||||
catalog.rows = stations.map((row) => row.id === ids[1]
|
||||
? { ...row, streamUrl: 'https://radio.example.com/b2.mp3' }
|
||||
: row);
|
||||
catalog.generation = 2;
|
||||
await radioLayer.update();
|
||||
const states = [];
|
||||
const controls = [];
|
||||
const unsubscribe = radioLayer.subscribe((state) => states.push(state));
|
||||
const unsubscribeControls = radioLayer.subscribePlaybackControls((event) => controls.push(event));
|
||||
states.length = 0;
|
||||
try {
|
||||
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).reason, 'station-unavailable');
|
||||
} finally {
|
||||
unsubscribe();
|
||||
unsubscribeControls();
|
||||
}
|
||||
assert.equal(states.length, 1);
|
||||
assert.equal(states[0].tuningUnavailableStationId, ids[1]);
|
||||
assert.deepEqual(controls.map(({ action, origin }) => [action, origin]), [['stop', 'user']]);
|
||||
const state = radioLayer.getUIState();
|
||||
assert.equal(state.selected, null);
|
||||
assert.equal(state.audioState, 'stopped');
|
||||
assert.equal(state.playingStationId, null);
|
||||
assert.equal(render(state).button, 'PLAY');
|
||||
playUrls.length = 0;
|
||||
// Play picks the nearest station; its broadcaster fails.
|
||||
const play = globalThis.Audio.prototype.play;
|
||||
globalThis.Audio.prototype.play = function playFirstFails() {
|
||||
if (!failing.length) failing.push(this.src);
|
||||
return play.call(this);
|
||||
};
|
||||
try {
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
await settle();
|
||||
await settle();
|
||||
} finally {
|
||||
globalThis.Audio.prototype.play = play;
|
||||
}
|
||||
assert.equal(playUrls.length, 2);
|
||||
assert.equal(radioLayer.getUIState().audioState, 'playing');
|
||||
});
|
||||
});
|
||||
|
||||
test('while a stream connects the transport buttons offer Pause, because a click pauses', () => {
|
||||
setLocale('en');
|
||||
const shown = render({
|
||||
enabled: true,
|
||||
audioState: 'loading',
|
||||
audioError: null,
|
||||
selected: { id: 'a', name: 'Station A' },
|
||||
selectedIndex: 0,
|
||||
filteredCount: 1,
|
||||
stationCount: 1,
|
||||
volume: 1,
|
||||
});
|
||||
assert.equal(shown.status, 'Connecting directly to broadcaster…');
|
||||
assert.equal(shown.button, 'PAUSE');
|
||||
assert.equal(shown.cockpit, 'Ⅱ');
|
||||
});
|
||||
|
||||
test('a dial release plays only if a stream was active when the drag began', async () => {
|
||||
await withLayer([], async ({ playUrls }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||
assert.equal(radioLayer.beginTuning(), true);
|
||||
radioLayer.previewTuningStation(ids[1], { rotate: false });
|
||||
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true);
|
||||
await settle();
|
||||
let state = radioLayer.getUIState();
|
||||
assert.deepEqual(playUrls, []);
|
||||
assert.equal(state.selected?.id, ids[1]);
|
||||
assert.equal(state.audioState, 'stopped');
|
||||
assert.equal(state.tuningStatic, false);
|
||||
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
assert.equal(radioLayer.beginTuning(), true);
|
||||
radioLayer.previewTuningStation(ids[2], { rotate: false });
|
||||
radioLayer.commitTuningStation(ids[2], { origin: 'user' });
|
||||
await settle();
|
||||
state = radioLayer.getUIState();
|
||||
assert.deepEqual(playUrls, [stations[1].streamUrl, stations[2].streamUrl]);
|
||||
assert.equal(state.audioState, 'playing');
|
||||
assert.equal(state.playingStationId, ids[2]);
|
||||
});
|
||||
});
|
||||
|
||||
// Like Chrome, resume() flips `state` only once the device starts, and suspend()
|
||||
// on a context that is still suspended resolves at once without effect.
|
||||
const RESUME_MS = 5;
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
function withFakeAudioContext(body) {
|
||||
const log = { contexts: [], sources: [] };
|
||||
const node = () => ({ connect() {}, disconnect() {} });
|
||||
class FakeAudioContext {
|
||||
constructor() {
|
||||
Object.assign(this, { state: 'suspended', sampleRate: 8000, currentTime: 0, destination: {} });
|
||||
log.contexts.push(this);
|
||||
}
|
||||
resume() {
|
||||
if (this.state === 'running') return Promise.resolve();
|
||||
return new Promise((resolve) => setTimeout(() => {
|
||||
if (this.state !== 'closed') this.state = 'running';
|
||||
resolve();
|
||||
}, RESUME_MS));
|
||||
}
|
||||
suspend() {
|
||||
if (this.state === 'running') this.state = 'suspended';
|
||||
return Promise.resolve();
|
||||
}
|
||||
close() { this.state = 'closed'; return Promise.resolve(); }
|
||||
createBuffer(_channels, length) { return { getChannelData: () => new Float32Array(length) }; }
|
||||
createBufferSource() {
|
||||
const source = { ...node(), started: false, stopped: false, start() { this.started = true; }, stop() { this.stopped = true; } };
|
||||
log.sources.push(source);
|
||||
return source;
|
||||
}
|
||||
createBiquadFilter() { return { ...node(), frequency: { value: 0 }, Q: { value: 0 } }; }
|
||||
createGain() {
|
||||
return { ...node(), gain: { value: 0, cancelScheduledValues() {}, setValueAtTime() {}, linearRampToValueAtTime() {} } };
|
||||
}
|
||||
}
|
||||
const original = globalThis.AudioContext;
|
||||
globalThis.AudioContext = FakeAudioContext;
|
||||
return Promise.resolve(body(log)).finally(() => {
|
||||
if (original === undefined) delete globalThis.AudioContext;
|
||||
else globalThis.AudioContext = original;
|
||||
});
|
||||
}
|
||||
|
||||
test('a silent dial release stops the tuner noise and suspends its audio context, even before resume() settles', async () => {
|
||||
await withFakeAudioContext(async ({ contexts, sources }) => {
|
||||
await withLayer([], async ({ playUrls }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||
assert.equal(radioLayer.beginTuning(), true);
|
||||
radioLayer.previewTuningStation(ids[1], { rotate: false });
|
||||
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true);
|
||||
await sleep(RESUME_MS * 4);
|
||||
assert.deepEqual(playUrls, []);
|
||||
assert.equal(sources[0].started, true);
|
||||
assert.equal(sources[0].stopped, true, 'noise source still looping');
|
||||
assert.equal(contexts[0].state, 'suspended', 'audio context still running');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('a second gesture before the first resume() settles keeps its noise graph', async () => {
|
||||
await withFakeAudioContext(async ({ contexts, sources }) => {
|
||||
await withLayer([], async () => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||
radioLayer.beginTuning();
|
||||
radioLayer.commitTuningStation(ids[0], { origin: 'user' });
|
||||
assert.equal(radioLayer.beginTuning(), true);
|
||||
await sleep(RESUME_MS * 4);
|
||||
assert.equal(contexts[0].state, 'running', 'second gesture lost its context');
|
||||
assert.equal(sources.at(-1).stopped, false, 'second gesture lost its source');
|
||||
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
|
||||
await sleep(RESUME_MS * 4);
|
||||
assert.equal(sources.at(-1).stopped, true);
|
||||
assert.equal(contexts[0].state, 'suspended');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('a dial release from a paused stream only selects, without static', async () => {
|
||||
await withFakeAudioContext(async ({ contexts }) => {
|
||||
await withLayer([], async ({ playUrls }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
|
||||
await settle();
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
assert.equal(radioLayer.getUIState().audioState, 'paused');
|
||||
assert.equal(radioLayer.beginTuning(), true);
|
||||
await sleep(RESUME_MS * 4);
|
||||
radioLayer.previewTuningStation(ids[1], { rotate: false });
|
||||
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
|
||||
await sleep(RESUME_MS * 4);
|
||||
const state = radioLayer.getUIState();
|
||||
assert.deepEqual(playUrls, [stations[0].streamUrl]);
|
||||
assert.equal(state.selected?.id, ids[1]);
|
||||
assert.equal(state.audioState, 'stopped');
|
||||
assert.equal(state.tuningStatic, false);
|
||||
assert.equal(state.tuningAwaitingStationId, null);
|
||||
assert.equal(contexts[0].state, 'suspended');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('a dial release while a stream is still connecting plays the new station', async () => {
|
||||
await withLayer([], async ({ playUrls }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
|
||||
assert.equal(radioLayer.getUIState().audioState, 'loading');
|
||||
assert.equal(radioLayer.beginTuning(), true);
|
||||
radioLayer.previewTuningStation(ids[1], { rotate: false });
|
||||
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
|
||||
await settle();
|
||||
const state = radioLayer.getUIState();
|
||||
assert.equal(playUrls.at(-1), stations[1].streamUrl);
|
||||
assert.equal(state.audioState, 'playing');
|
||||
assert.equal(state.playingStationId, ids[1]);
|
||||
});
|
||||
});
|
||||
|
||||
test('a silent user step drops the fallback a voice step left behind', async () => {
|
||||
await withLayer(stations.map(({ streamUrl }) => streamUrl), async ({ playUrls }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||
// Voice steps to B and keeps C as its fallback; the user steps back to A.
|
||||
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
|
||||
radioLayer.cycleStation(-1, { stationIds: ids, autoplay: false, origin: 'user' });
|
||||
await radioLayer.play({ origin: 'voice' });
|
||||
await settle();
|
||||
await settle();
|
||||
assert.deepEqual(playUrls, [stations[0].streamUrl]);
|
||||
assert.equal(radioLayer.getUIState().audioState, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
test('a silent dial release drops the fallback a voice step left behind', async () => {
|
||||
await withLayer(stations.map(({ streamUrl }) => streamUrl), async ({ playUrls }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
|
||||
assert.equal(radioLayer.beginTuning(), true);
|
||||
radioLayer.previewTuningStation(ids[0], { rotate: false });
|
||||
radioLayer.commitTuningStation(ids[0], { origin: 'user' });
|
||||
await radioLayer.play({ origin: 'voice' });
|
||||
await settle();
|
||||
await settle();
|
||||
assert.deepEqual(playUrls, [stations[0].streamUrl]);
|
||||
assert.equal(radioLayer.getUIState().audioState, 'error');
|
||||
});
|
||||
});
|
||||
@@ -541,6 +541,191 @@ 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'}));
|
||||
@@ -599,3 +784,29 @@ 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);
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
resolveEllipsoidalGround,
|
||||
cachedEllipsoidalGround,
|
||||
cachedRealEllipsoidalGround,
|
||||
} from './terrainHeights.js';
|
||||
|
||||
const AUSTIN = { lat: 30.2672, lon: -97.7431 };
|
||||
@@ -362,3 +363,47 @@ test('resolveEllipsoidalGround: null and string heights cannot become real zeroe
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveEllipsoidalGround: a 502 keeps the real heights it carries and falls back only for the rest', async () => {
|
||||
const coords = [
|
||||
{ lat: 62.001, lon: -150.001, sourceOrthometricM: 10 },
|
||||
{ lat: 62.002, lon: -150.002, sourceOrthometricM: 10 },
|
||||
{ lat: 62.003, lon: -150.003, sourceOrthometricM: 10 },
|
||||
];
|
||||
await withFakeFetch(
|
||||
async () => ({
|
||||
ok: false,
|
||||
status: 502,
|
||||
json: async () => ({
|
||||
error: 'terrain heights fetch failed and no cache available for every point',
|
||||
partial: [{ ellipsoid: 412.5 }, null, { ellipsoid: 'x' }],
|
||||
}),
|
||||
}),
|
||||
async () => {
|
||||
const out = await resolveEllipsoidalGround(coords);
|
||||
assert.deepEqual(out[0], { ellipsoid: 412.5, source: 'reearth' });
|
||||
assert.equal(out[1].source, 'geoid-fallback');
|
||||
assert.equal(out[2].source, 'geoid-fallback', 'a non-numeric height is not real');
|
||||
}
|
||||
);
|
||||
assert.equal(cachedRealEllipsoidalGround(62.001, -150.001), 412.5);
|
||||
assert.equal(cachedRealEllipsoidalGround(62.002, -150.002), null);
|
||||
});
|
||||
|
||||
test('resolveEllipsoidalGround: a partial list of the wrong length is ignored', async () => {
|
||||
const coords = [
|
||||
{ lat: 63.001, lon: -151.001 },
|
||||
{ lat: 63.002, lon: -151.002 },
|
||||
];
|
||||
await withFakeFetch(
|
||||
async () => ({
|
||||
ok: false,
|
||||
status: 502,
|
||||
json: async () => ({ error: 'x', partial: [{ ellipsoid: 7 }] }),
|
||||
}),
|
||||
async () => {
|
||||
const out = await resolveEllipsoidalGround(coords);
|
||||
assert.deepEqual(out.map((r) => r.source), ['geoid-fallback', 'geoid-fallback']);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -190,7 +190,8 @@ export async function fetchTerrainChunkWithRetry(
|
||||
* Resolve a requested point batch against a per-point cache and a missing-only
|
||||
* fetcher. Results are reconstructed by canonical key in exact request order,
|
||||
* including duplicates. Stale values remain eligible only when refresh fails.
|
||||
* If any position has no real height, return 502 with no fabricated result.
|
||||
* If any position has no real height, return 502 with no fabricated result;
|
||||
* the body's `partial` lists the real heights it has, null for the rest.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {Array<[number, number]>} options.points
|
||||
@@ -269,11 +270,19 @@ export async function resolveTerrainHeightRequest({
|
||||
return entry && validTerrainResult(entry.result) ? entry.result : null;
|
||||
});
|
||||
if (results.some((result) => result == null)) {
|
||||
// The real heights this request does have, null elsewhere, so the page
|
||||
// keeps them and uses its geoid only for the rest. Named `partial`, not
|
||||
// `results`, so no reader mistakes it for a complete answer; left out when
|
||||
// no point has a height.
|
||||
const partial = results.some((result) => result != null)
|
||||
? { partial: results }
|
||||
: {};
|
||||
return {
|
||||
status: 502,
|
||||
body: {
|
||||
error:
|
||||
'terrain heights fetch failed and no cache available for every point',
|
||||
...partial,
|
||||
},
|
||||
cacheChanged,
|
||||
upstreamError,
|
||||
|
||||
@@ -268,3 +268,31 @@ test('terrain parser rejects out-of-range WGS84 points and accepts inclusive bou
|
||||
[0, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a 502 lists the real heights it has, null for the rest', async () => {
|
||||
const cache = new Map([
|
||||
[terrainPointKey([1, 1]), { at: 0, result: result('stale-a', 11) }],
|
||||
]);
|
||||
const response = await resolveTerrainHeightRequest({
|
||||
points: [
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
[1, 1],
|
||||
],
|
||||
cache,
|
||||
ttlMs: 100,
|
||||
now: () => 1000,
|
||||
fetchMissing: async () => {
|
||||
const error = new Error('HTTP 429');
|
||||
error.status = 429;
|
||||
error.coolingDown = true;
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
assert.equal(response.status, 502);
|
||||
assert.equal('results' in response.body, false);
|
||||
assert.deepEqual(
|
||||
response.body.partial.map((item) => item?.id ?? null),
|
||||
['stale-a', null, 'stale-a'],
|
||||
);
|
||||
});
|
||||
@@ -983,7 +983,13 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
// ordering while allowing source formatting. Derived from the unchanged
|
||||
// release schema before formatting (the previous source-byte pin passed).
|
||||
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter(
|
||||
(tool) => tool.name !== 'set_cyber_sonar',
|
||||
(tool) =>
|
||||
![
|
||||
'set_cyber_sonar',
|
||||
'resolve_area',
|
||||
'find_imagery',
|
||||
'osm_query',
|
||||
].includes(tool.name),
|
||||
);
|
||||
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud')
|
||||
.parameters.properties.layout;
|
||||
@@ -1030,11 +1036,13 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
// preserves the mission mapping and globe-rotation arguments.
|
||||
// S2 GhostMaps extends the generated layer enums; both feature contracts remain.
|
||||
// The layer alias hint leaves out aliases the layer id already spells.
|
||||
// The Utilities panel adds one set_panel_open target.
|
||||
assert.equal(block.length, 30919, 'serialized tool schema length drifted');
|
||||
// The Utilities panel adds one set_panel_open target; Sea Marks and Ski
|
||||
// Pistes extend the generated layer enums and alias hint; voice geometry
|
||||
// adds the analyst area scopes and the osm-places layer.
|
||||
assert.equal(block.length, 31318, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'dbf9a45649179e626b2e3d938c43903f573c50ffa279b3245b18244dbe9a9284',
|
||||
'f5b42f41c2f575838f979e88a1dbff615a77ccd85fdf8fcd0a0a47d02f7d3ddc',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PassThrough } from 'node:stream';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { localProviderPlugins } from '../server/providers/local.js';
|
||||
import { standaloneVoiceTools } from '../server/standalone/voiceTools.js';
|
||||
import { offeredTools } from '../server/providers/openai/tools.js';
|
||||
import {
|
||||
KEY_SETUP_KEYS,
|
||||
keySetupStatus,
|
||||
@@ -85,13 +86,15 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
assert.equal(calls.length, 1);
|
||||
const locked = JSON.parse(calls[0].options.body).bidiGenerateContentSetup
|
||||
.tools[0].functionDeclarations;
|
||||
// Without an operator Overpass, osm_query is left out of the session.
|
||||
const offered = offeredTools(tools);
|
||||
assert.deepEqual(
|
||||
locked.map(({ name }) => name),
|
||||
tools.map(({ name }) => name),
|
||||
offered.map(({ name }) => name),
|
||||
);
|
||||
assert.deepEqual(
|
||||
locked.map(({ parametersJsonSchema }) => parametersJsonSchema),
|
||||
tools.map(({ parameters }) => parameters),
|
||||
offered.map(({ parameters }) => parameters),
|
||||
);
|
||||
assert.ok(locked.every(({ behavior }) => behavior === 'BLOCKING'));
|
||||
assert.deepEqual(res.body.config.tools[0].functionDeclarations, locked);
|
||||
|
||||
+17
-12
@@ -16,7 +16,11 @@ import {
|
||||
GEMINI_LIVE_WEBSOCKET_URL,
|
||||
createGeminiLiveConfig,
|
||||
} from '../server/providers/gemini/config.js';
|
||||
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
|
||||
import {
|
||||
GEV_REALTIME_TOOLS,
|
||||
offeredTools,
|
||||
realtimeTools,
|
||||
} from '../server/providers/openai/tools.js';
|
||||
import { PROXY_SIGNALS } from './localRequestGate.mjs';
|
||||
|
||||
const API_KEY = 'private-gemini-test-key-never-return';
|
||||
@@ -219,16 +223,15 @@ test('mint locks canonical action schemas and server instructions with no perman
|
||||
undefined,
|
||||
);
|
||||
const functions = data.config.tools[0].functionDeclarations;
|
||||
// Without an operator Overpass, osm_query is left out of the session.
|
||||
const offered = realtimeTools();
|
||||
assert.deepEqual(
|
||||
functions.map((tool) => tool.name),
|
||||
GEV_REALTIME_TOOLS.map((tool) => tool.name),
|
||||
offered.map((tool) => tool.name),
|
||||
);
|
||||
for (let i = 0; i < functions.length; i++) {
|
||||
assert.equal(functions[i].behavior, 'BLOCKING');
|
||||
assert.deepEqual(
|
||||
functions[i].parametersJsonSchema,
|
||||
GEV_REALTIME_TOOLS[i].parameters,
|
||||
);
|
||||
assert.deepEqual(functions[i].parametersJsonSchema, offered[i].parameters);
|
||||
assert.equal(functions[i].parameters, undefined);
|
||||
}
|
||||
const [url, init] = f.calls[0];
|
||||
@@ -876,12 +879,14 @@ test(
|
||||
|
||||
test('standalone mints lock the full voice query catalog in both credential and browser setup', async (t) => {
|
||||
const tools = standaloneVoiceTools();
|
||||
const declarations = tools.map(({ name, description, parameters }) => ({
|
||||
name,
|
||||
description,
|
||||
parametersJsonSchema: parameters,
|
||||
behavior: 'BLOCKING',
|
||||
}));
|
||||
const declarations = offeredTools(tools).map(
|
||||
({ name, description, parameters }) => ({
|
||||
name,
|
||||
description,
|
||||
parametersJsonSchema: parameters,
|
||||
behavior: 'BLOCKING',
|
||||
}),
|
||||
);
|
||||
for (const name of ['search_places', 'get_weather', 'get_wind', 'plan_route'])
|
||||
assert.ok(
|
||||
tools.some((tool) => tool.name === name),
|
||||
|
||||
+152
-40
@@ -32,17 +32,31 @@ import {
|
||||
} from './data/geoid.js';
|
||||
import { getBasemapLabelContext } from './voice/gevActions.js';
|
||||
import {
|
||||
hudSummaryMatchesProvenance,
|
||||
hasWord,
|
||||
hudSummaryHasEnglish,
|
||||
hudSummaryIsLatin,
|
||||
hudSummaryLayerContext,
|
||||
hudTelemetryProvenance,
|
||||
isHudSummaryUnconfigured,
|
||||
} from './hudSummaryResponse.js';
|
||||
import {
|
||||
hudSummaryMatchesProvenance,
|
||||
provenanceLine,
|
||||
provenanceTag,
|
||||
} from './data/layerSnapshot.js';
|
||||
import {
|
||||
applyHudUiTheme,
|
||||
DEFAULT_HUD_LAYOUT,
|
||||
normalizeHudLayout,
|
||||
} from './hudLayouts.js';
|
||||
import { formatNumber, msg, onLocaleChange, t } from './i18n/index.js';
|
||||
import {
|
||||
formatNumber,
|
||||
getLocale,
|
||||
msg,
|
||||
onLocaleChange,
|
||||
t,
|
||||
} from './i18n/index.js';
|
||||
import { isRtlLocale } from './i18n/locale.js';
|
||||
import { localizeLayerName } from './ui/layerPanel.js';
|
||||
import { isEmbeddedInline } from './app/embed.js';
|
||||
import { subscribeSelectedVessel } from './hudReadouts.js';
|
||||
@@ -108,23 +122,58 @@ const FEED_STATE_LABELS = Object.freeze({
|
||||
unavailable: msg('UNAVAILABLE'),
|
||||
});
|
||||
|
||||
/** A summary line with its warning in the active language. */
|
||||
function withProvenance(text, provenance) {
|
||||
return provenanceLine(
|
||||
text,
|
||||
provenance,
|
||||
(state) =>
|
||||
FEED_STATE_LABELS[state]
|
||||
? t(FEED_STATE_LABELS[state])
|
||||
: state.toUpperCase(),
|
||||
(name) => localizeLayerName(name).toUpperCase(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The summary's feed-state suffix in the active language, e.g.
|
||||
* `UNAVAILABLE: LIVE AIS VESSELS`. State ids and layer names stay English in
|
||||
* layer data; only this line translates them.
|
||||
* The AI line. In an RTL locale a leading right-to-left mark keeps a reply
|
||||
* that opens with a Latin name (Times Square …) reading right to left.
|
||||
*/
|
||||
function localizedProvenanceTag(provenance) {
|
||||
if (!provenance) return null;
|
||||
const state = FEED_STATE_LABELS[provenance.state]
|
||||
? t(FEED_STATE_LABELS[provenance.state])
|
||||
: provenance.state.toUpperCase();
|
||||
if (!provenance.names.length) return state;
|
||||
return t('{state} {layers}', {
|
||||
state,
|
||||
layers: provenance.names
|
||||
.map((name) => localizeLayerName(name).toUpperCase())
|
||||
.join('/'),
|
||||
});
|
||||
function aiSummaryLine(summary, warning) {
|
||||
const line = withProvenance(summary, warning);
|
||||
return isRtlLocale(getLocale()) ? `\u200f${line}` : line;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a summary outside English must not carry: feed-state ids, translated
|
||||
* layers' English names and ids, and with every feed healthy the
|
||||
* UNAVAILABLE word. A state word the locale keeps in English (de FALLBACK)
|
||||
* and words of a place label (Radio City, Loading Dock) are fine; so is a
|
||||
* one-word layer name in a Latin-script locale, where it may be a word of
|
||||
* the language (fr transit).
|
||||
*/
|
||||
function summaryForbiddenWords(context) {
|
||||
const places = [
|
||||
...(context.placeLabels || []),
|
||||
...(context.streetLabels || []),
|
||||
...(context.nearbyPlaceLabels || []),
|
||||
].join(' ');
|
||||
const latin = hudSummaryIsLatin(context.locale);
|
||||
const overall = context.feedProvenance?.overall;
|
||||
return [
|
||||
...Object.keys(FEED_STATE_LABELS).filter(
|
||||
(state) =>
|
||||
t(FEED_STATE_LABELS[state]).toLowerCase() !== state &&
|
||||
!hasWord(places, state),
|
||||
),
|
||||
...(context.englishLayerWords || []).filter(
|
||||
(word) =>
|
||||
!hasWord(places, word) && (!latin || /[^\p{L}\p{N}]/u.test(word)),
|
||||
),
|
||||
...(!overall || overall === 'nominal'
|
||||
? [t(FEED_STATE_LABELS.unavailable)]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
/** The name shown for a _viewBand() id. */
|
||||
@@ -221,7 +270,7 @@ export class IntelHUD {
|
||||
this._placeLabels = null;
|
||||
this._lastSummarySignature = '';
|
||||
/** The reply painted for `_lastSummarySignature`, when it was accepted. */
|
||||
this._lastSummaryText = null;
|
||||
this._lastSummaryReply = null;
|
||||
this._summaryRevision = 0;
|
||||
// One-shot guards so the very first summary lands immediately instead of
|
||||
// waiting for the 15s interval tick: B) swap the "Awaiting telemetry..."
|
||||
@@ -250,12 +299,17 @@ export class IntelHUD {
|
||||
// the camera has moved to.
|
||||
this._onCameraMoveStart = () => {
|
||||
this._markSummaryDirty();
|
||||
if (this._summaryRequest || this._summaryTypingInterval) {
|
||||
const typing = Boolean(this._summaryTypingInterval);
|
||||
if (this._summaryRequest || typing) {
|
||||
// Cancelled requests and partially typed replies are not reusable answers.
|
||||
this._lastSummarySignature = null;
|
||||
}
|
||||
this._summaryRequest?.abort();
|
||||
this._stopSummaryTyping();
|
||||
// A half-typed reply would stay until moveEnd, which a camera that
|
||||
// keeps moving may not send for a long time: show the plain line.
|
||||
if (typing && this._latestMetrics)
|
||||
this._setSummaryText(this._composeSummary(), false);
|
||||
};
|
||||
this._onCameraMoveEnd = () => {
|
||||
this._markSummaryDirty();
|
||||
@@ -295,12 +349,12 @@ export class IntelHUD {
|
||||
this._startTimers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the HUD's own words in the new locale. The deterministic summary
|
||||
* is composed again; the AI reply does not depend on the locale, so the
|
||||
* next tick shows the one already received rather than asking again.
|
||||
*/
|
||||
/** Show the HUD's words in the new locale; drop AI text in the old one. */
|
||||
_relocalize() {
|
||||
this._markSummaryDirty();
|
||||
this._lastSummarySignature = null;
|
||||
this._lastSummaryReply = null;
|
||||
this._summaryRequest?.abort();
|
||||
this._present({
|
||||
mode: MODE_LABELS[this._currentStyle]
|
||||
? t(MODE_LABELS[this._currentStyle])
|
||||
@@ -763,8 +817,8 @@ export class IntelHUD {
|
||||
LOCALIZED_LOCALITY,
|
||||
);
|
||||
|
||||
const provenance = localizedProvenanceTag(
|
||||
hudTelemetryProvenance(this._dataManager?.getAll?.() || []),
|
||||
const provenance = hudTelemetryProvenance(
|
||||
this._dataManager?.getAll?.() || [],
|
||||
);
|
||||
// ALT, SUN and ONA are sensor codes, as in the corner readouts.
|
||||
const line = t(
|
||||
@@ -781,7 +835,7 @@ export class IntelHUD {
|
||||
zone: localTag,
|
||||
},
|
||||
);
|
||||
return provenance ? `${line} | ${provenance}` : line;
|
||||
return withProvenance(line, provenance);
|
||||
}
|
||||
|
||||
/** Stop a summary still typing out, leaving what it has typed so far. */
|
||||
@@ -854,12 +908,28 @@ export class IntelHUD {
|
||||
return;
|
||||
}
|
||||
if (revision !== this._summaryRevision) return;
|
||||
// Without a place label the model invents a place: show the plain line.
|
||||
const described = [
|
||||
context.placeLabels,
|
||||
context.streetLabels,
|
||||
context.nearbyPlaceLabels,
|
||||
].some((list) => list?.length);
|
||||
if (!described) {
|
||||
this._summaryDirty = false;
|
||||
this._setSummaryText(fallbackText, false);
|
||||
return;
|
||||
}
|
||||
const signature = JSON.stringify(context);
|
||||
if (!force && signature === this._lastSummarySignature) {
|
||||
this._summaryDirty = false;
|
||||
// A settle painted the fallback line over the reply this context
|
||||
// already has; reused place labels make that common, so restore it.
|
||||
if (this._lastSummaryText) this._setSummaryText(this._lastSummaryText);
|
||||
// The warning is the current one: layer states are not in a
|
||||
// non-English signature.
|
||||
if (this._lastSummaryReply)
|
||||
this._setSummaryText(
|
||||
aiSummaryLine(this._lastSummaryReply, context.warning),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this._summaryRequest) return;
|
||||
@@ -867,7 +937,7 @@ export class IntelHUD {
|
||||
if (force) this._setSummaryText(fallbackText, false);
|
||||
this._summaryDirty = false;
|
||||
this._lastSummarySignature = signature;
|
||||
this._lastSummaryText = null;
|
||||
this._lastSummaryReply = null;
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timeout = window.setTimeout(() => {
|
||||
@@ -890,9 +960,23 @@ export class IntelHUD {
|
||||
if (!response.ok || !data?.summary) {
|
||||
throw new Error(data?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
if (hudSummaryMatchesProvenance(data.summary, context.feedProvenance))
|
||||
this._lastSummaryText = data.summary;
|
||||
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
|
||||
if (
|
||||
hudSummaryMatchesProvenance(data, context.feedProvenance) &&
|
||||
!hudSummaryHasEnglish(
|
||||
data.summary,
|
||||
context.locale,
|
||||
summaryForbiddenWords(context),
|
||||
)
|
||||
) {
|
||||
this._lastSummaryReply = data.summary;
|
||||
}
|
||||
// The warning is the HUD's own translated state, not the model's words.
|
||||
this._setSummaryText(
|
||||
this._lastSummaryReply
|
||||
? aiSummaryLine(this._lastSummaryReply, context.warning)
|
||||
: fallbackText,
|
||||
animate,
|
||||
);
|
||||
} catch (error) {
|
||||
// The 5 s timeout is a failure like any other; a move's abort (or
|
||||
// destroy's) is not.
|
||||
@@ -950,18 +1034,46 @@ export class IntelHUD {
|
||||
// A slower build for a view the camera has left must not replace the
|
||||
// newer view's lookup.
|
||||
if (revision === this._summaryRevision) this._placeLabels = labels;
|
||||
const enabledLayers =
|
||||
this._dataManager
|
||||
?.getAll?.()
|
||||
?.filter((layer) => layer.enabled)
|
||||
.map((layer) => layer.name) || [];
|
||||
return {
|
||||
const locale = getLocale();
|
||||
const layerContext = hudSummaryLayerContext(
|
||||
this._dataManager?.getAll?.() || [],
|
||||
);
|
||||
// Outside English a layer's state id is left out: the model echoes it,
|
||||
// and `overall` already carries the state.
|
||||
const named = ({ feedState, ...layer }) => ({
|
||||
...layer,
|
||||
name: localizeLayerName(layer.name),
|
||||
...(locale === 'en' && { feedState }),
|
||||
});
|
||||
const { feedProvenance } = layerContext;
|
||||
const context = {
|
||||
locale,
|
||||
placeLabels: labels.placeLabels,
|
||||
streetLabels: labels.streetLabels,
|
||||
nearbyPlaceLabels: labels.nearbyPlaceLabels,
|
||||
enabledLayerLabels: enabledLayers,
|
||||
...hudSummaryLayerContext(this._dataManager?.getAll?.() || []),
|
||||
// The model sees only the layer names the user sees. The English note
|
||||
// goes too: a small model copies its words. `overall` stays the id.
|
||||
enabledLayerLabels:
|
||||
layerContext.enabledLayerLabels.map(localizeLayerName),
|
||||
enabledLayers: layerContext.enabledLayers.map(named),
|
||||
feedProvenance: {
|
||||
...feedProvenance,
|
||||
layers: feedProvenance.layers.map(named),
|
||||
note: locale === 'en' ? feedProvenance.note : undefined,
|
||||
},
|
||||
};
|
||||
// Not sent and not part of the signature: the line's own warning, taken
|
||||
// before the layer states are left out, and the English names and ids of
|
||||
// translated layers, which a reply in another language must not echo.
|
||||
Object.defineProperties(context, {
|
||||
warning: { value: provenanceTag(feedProvenance) },
|
||||
englishLayerWords: {
|
||||
value: layerContext.enabledLayers
|
||||
.filter(({ name }) => localizeLayerName(name) !== name)
|
||||
.flatMap(({ name, id }) => [name, id].filter(Boolean)),
|
||||
},
|
||||
});
|
||||
return context;
|
||||
}
|
||||
|
||||
_markSummaryDirty() {
|
||||
|
||||
@@ -295,7 +295,7 @@ test('a summary asked for one view is dropped once the camera moves', async () =
|
||||
try {
|
||||
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
|
||||
let place = 'Yellowstone';
|
||||
hud._summaryContext = async () => ({ place });
|
||||
hud._summaryContext = async () => ({ placeLabels: [place] });
|
||||
const summary = () => env.elements.get('hud-summary').textContent;
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
hud._updateCameraData();
|
||||
@@ -321,7 +321,7 @@ test('a summary asked for one view is dropped once the camera moves', async () =
|
||||
const moving = hud._updateSummary(false);
|
||||
await settle();
|
||||
assert.equal(pending.length, 2, 'the move marked the summary dirty');
|
||||
assert.deepEqual(pending[1].context, { place: 'Atlantic' });
|
||||
assert.deepEqual(pending[1].context, { placeLabels: ['Atlantic'] });
|
||||
pending[1].reply('North Atlantic Ocean');
|
||||
await moving;
|
||||
assert.equal(summary(), 'North Atlantic Ocean');
|
||||
@@ -354,7 +354,7 @@ test('a request the camera leaves paints nothing: aborted, failed, or its contex
|
||||
let hud;
|
||||
try {
|
||||
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
|
||||
hud._summaryContext = async () => ({ place: 'Yellowstone' });
|
||||
hud._summaryContext = async () => ({ placeLabels: ['Yellowstone'] });
|
||||
const element = env.elements.get('hud-summary');
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
hud._updateCameraData();
|
||||
@@ -416,7 +416,7 @@ test('a cancelled summary retries the same context but completed answers stay re
|
||||
let hud;
|
||||
try {
|
||||
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
|
||||
hud._summaryContext = async () => ({ place: 'San Francisco' });
|
||||
hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] });
|
||||
hud._updateCameraData();
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
const first = hud._updateSummary(false);
|
||||
@@ -474,7 +474,7 @@ test('after a dropped or timed-out request the next tick asks again, and a move
|
||||
try {
|
||||
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
|
||||
// The same place before and after: a slow follow camera, a small pan.
|
||||
hud._summaryContext = async () => ({ place: 'Lisboa' });
|
||||
hud._summaryContext = async () => ({ placeLabels: ['Lisboa'] });
|
||||
const element = env.elements.get('hud-summary');
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
hud._updateCameraData();
|
||||
@@ -501,15 +501,17 @@ test('after a dropped or timed-out request the next tick asks again, and a move
|
||||
});
|
||||
await update;
|
||||
|
||||
// A reply still typing stops when the camera starts moving.
|
||||
// A reply still typing stops when the camera starts moving, and the
|
||||
// plain line replaces the fragment.
|
||||
hud._typeSummary('Canyon Junction Wyoming Yellowstone National Park');
|
||||
t.mock.timers.tick(48);
|
||||
const typed = element.textContent;
|
||||
env.cameraListeners.moveStart();
|
||||
const plain = element.textContent;
|
||||
assert.equal(plain, hud._composeSummary());
|
||||
t.mock.timers.tick(2_000);
|
||||
assert.equal(
|
||||
element.textContent,
|
||||
typed,
|
||||
plain,
|
||||
'no more characters after the move',
|
||||
);
|
||||
} finally {
|
||||
@@ -536,7 +538,7 @@ test('a summary interrupted while typing can retry unchanged context', async (t)
|
||||
let hud;
|
||||
try {
|
||||
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
|
||||
hud._summaryContext = async () => ({ place: 'San Francisco' });
|
||||
hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] });
|
||||
hud._updateCameraData();
|
||||
await hud._updateSummary(true);
|
||||
t.mock.timers.tick(48);
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import './testSupport/stubMgrs.mjs';
|
||||
import { hudReadouts } from './testSupport/hudReadouts.mjs';
|
||||
|
||||
const { IntelHUD } = await import('./hud.js');
|
||||
const { setLocale } = await import('./i18n/index.js');
|
||||
const { hudSummaryHasEnglish, hudSummaryInput } =
|
||||
await import('./hudSummaryResponse.js');
|
||||
|
||||
const PARTIAL_FLIGHTS = {
|
||||
id: 'flights',
|
||||
name: 'Live Flights',
|
||||
enabled: true,
|
||||
stats: { count: 4, partial: true, source: 'opensky' },
|
||||
};
|
||||
|
||||
function createHud(
|
||||
t,
|
||||
reply,
|
||||
{ places = ['Остин'], locale = 'ru', layers = () => [PARTIAL_FLIGHTS] } = {},
|
||||
) {
|
||||
const { elements, publish } = hudReadouts();
|
||||
const previous = { document: globalThis.document, window: globalThis.window };
|
||||
globalThis.document = {
|
||||
hidden: false,
|
||||
visibilityState: 'visible',
|
||||
getElementById: (id) => elements.get(id) ?? null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
globalThis.window ??= {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
};
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
const viewer = {
|
||||
scene: { canvas: { width: 0, height: 0 }, pickPositionSupported: false },
|
||||
camera: {
|
||||
pitch: -Math.PI / 2,
|
||||
heading: 0,
|
||||
positionCartographic: { latitude: 0.53, longitude: -1.7, height: 600 },
|
||||
computeViewRectangle: () => undefined,
|
||||
moveStart: { addEventListener() {}, removeEventListener() {} },
|
||||
moveEnd: { addEventListener() {}, removeEventListener() {} },
|
||||
},
|
||||
};
|
||||
const sent = [];
|
||||
const hud = new IntelHUD(viewer, {
|
||||
publish,
|
||||
summaryService: {
|
||||
configured: async () => true,
|
||||
async summarize(context) {
|
||||
sent.push(context);
|
||||
return { ok: true, status: 200, data: { error: null, ...reply } };
|
||||
},
|
||||
},
|
||||
});
|
||||
t.after(() => {
|
||||
hud.destroy();
|
||||
setLocale('en');
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) delete globalThis[key];
|
||||
else globalThis[key] = value;
|
||||
}
|
||||
});
|
||||
// The test viewer has no view target, so no place lookup runs.
|
||||
const buildContext = hud._summaryContext.bind(hud);
|
||||
hud._summaryContext = async () =>
|
||||
Object.assign(await buildContext(), { placeLabels: places });
|
||||
hud._dataManager = { getAll: layers };
|
||||
hud._latestMetrics = { altM: 600 };
|
||||
setLocale(locale);
|
||||
return {
|
||||
hud,
|
||||
sent,
|
||||
shown: () => elements.get('hud-summary').textContent,
|
||||
};
|
||||
}
|
||||
|
||||
test('a ru HUD request carries no English layer name', async (t) => {
|
||||
const { hud } = createHud(t, {});
|
||||
const context = await hud._summaryContext();
|
||||
assert.equal(context.locale, 'ru');
|
||||
assert.deepEqual(context.enabledLayerLabels, ['Рейсы онлайн']);
|
||||
const input = hudSummaryInput(context);
|
||||
assert.doesNotMatch(input, /Live Flights/, input);
|
||||
// The locale signs the dedupe signature; the English names stay local.
|
||||
assert.match(JSON.stringify(context), /"locale":"ru"/);
|
||||
assert.doesNotMatch(JSON.stringify(context), /englishLayerWords|warning/);
|
||||
});
|
||||
|
||||
test('a ru summary shows the state word first and the layer names last', async (t) => {
|
||||
const { hud, shown } = createHud(t, {
|
||||
summary: 'Аэропорт задерживает рейсы',
|
||||
feedState: 'partial',
|
||||
});
|
||||
await hud._updateSummary(false, true);
|
||||
assert.equal(shown(), 'ЧАСТИЧНО | Аэропорт задерживает рейсы | РЕЙСЫ ОНЛАЙН');
|
||||
});
|
||||
|
||||
for (const words of [
|
||||
'Live Flights частично онлайн',
|
||||
'Рейсы онлайн stale данные',
|
||||
'Остин flights частично онлайн',
|
||||
'Austin flights partially online',
|
||||
]) {
|
||||
test(`a ru reply echoing English falls back: ${words}`, async (t) => {
|
||||
const { hud, shown } = createHud(t, {
|
||||
summary: words,
|
||||
feedState: 'partial',
|
||||
});
|
||||
await hud._updateSummary(false, true);
|
||||
assert.ok(shown().startsWith('ЧАСТИЧНО | '), shown());
|
||||
assert.ok(!shown().includes(words), shown());
|
||||
});
|
||||
}
|
||||
|
||||
test('a view with layers but no place label does not ask the model', async (t) => {
|
||||
const { hud, sent, shown } = createHud(
|
||||
t,
|
||||
{ summary: 'Москва Рейсы онлайн частично', feedState: 'partial' },
|
||||
{ places: [] },
|
||||
);
|
||||
await hud._updateSummary(false, true);
|
||||
assert.equal(sent.length, 0);
|
||||
assert.equal(shown(), hud._composeSummary());
|
||||
});
|
||||
|
||||
test('a ja request leaves the layer state ids to `overall`', async (t) => {
|
||||
const { hud } = createHud(t, {}, { locale: 'ja' });
|
||||
const context = await hud._summaryContext();
|
||||
assert.equal(context.feedProvenance.overall, 'partial');
|
||||
assert.ok(!('feedState' in context.enabledLayers[0]));
|
||||
assert.ok(!('feedState' in context.feedProvenance.layers[0]));
|
||||
});
|
||||
|
||||
for (const [locale, summary] of [
|
||||
['ja', '東京上空のstale航空情報'],
|
||||
['ja', '東京上空のLive Flights'],
|
||||
['zh-CN', '北京航班stale数据'],
|
||||
['ru', 'Остин Live-Flights частично'],
|
||||
]) {
|
||||
test(`English flush against other text is an echo: ${summary}`, () => {
|
||||
assert.ok(hudSummaryHasEnglish(summary, locale, ['stale', 'Live Flights']));
|
||||
});
|
||||
}
|
||||
|
||||
test('English inside a longer Latin word is not an echo', () => {
|
||||
assert.ok(!hudSummaryHasEnglish('Datos stalesque', 'es', ['stale']));
|
||||
});
|
||||
|
||||
for (const [locale, summary, places] of [
|
||||
['es', 'Tráfico aéreo nominal sobre Madrid', ['Madrid']],
|
||||
['de', 'Fallback Daten über Berlin', ['Berlin']],
|
||||
['ja', 'Live Flights Plaza周辺', ['Live Flights Plaza']],
|
||||
['es', 'Estación Loading Dock Madrid', ['Estación Loading Dock']],
|
||||
]) {
|
||||
test(`a ${locale} word that is not an echo is shown: ${summary}`, async (t) => {
|
||||
const { hud, shown } = createHud(
|
||||
t,
|
||||
{ summary, feedState: 'partial' },
|
||||
{ places, locale },
|
||||
);
|
||||
await hud._updateSummary(false, true);
|
||||
assert.ok(shown().includes(summary), shown());
|
||||
});
|
||||
}
|
||||
|
||||
test('a language switch drops the reply in flight and asks again', async (t) => {
|
||||
const { hud, sent, shown } = createHud(t, {});
|
||||
let answer;
|
||||
hud.summaryService.summarize = (context, { signal }) => {
|
||||
sent.push(context);
|
||||
return new Promise((resolve, reject) => {
|
||||
answer = () =>
|
||||
resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: {
|
||||
error: null,
|
||||
summary: 'Остин рейсы частично',
|
||||
feedState: 'partial',
|
||||
},
|
||||
});
|
||||
signal.addEventListener('abort', () =>
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
|
||||
);
|
||||
});
|
||||
};
|
||||
const pending = hud._updateSummary(false, true);
|
||||
while (!answer) await new Promise((resolve) => setImmediate(resolve));
|
||||
setLocale('es');
|
||||
answer();
|
||||
await pending;
|
||||
assert.ok(!shown().includes('Остин'), shown());
|
||||
assert.equal(shown(), hud._composeSummary());
|
||||
|
||||
hud.summaryService.summarize = async (context) => {
|
||||
sent.push(context);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: {
|
||||
error: null,
|
||||
summary: 'Austin vuelos parciales',
|
||||
feedState: 'partial',
|
||||
},
|
||||
};
|
||||
};
|
||||
await hud._updateSummary(false);
|
||||
assert.equal(sent.at(-1).locale, 'es');
|
||||
assert.ok(shown().startsWith('PARCIAL | Austin vuelos parciales'), shown());
|
||||
});
|
||||
|
||||
test('a reused ru reply carries the current warning, not the cached one', async (t) => {
|
||||
// Both layers load an empty view: no count, no lastUpdate yet.
|
||||
let flights = { loading: true, count: 0 };
|
||||
const { hud, shown } = createHud(
|
||||
t,
|
||||
{ summary: 'Остин небо над городом', feedState: 'loading' },
|
||||
{
|
||||
layers: () => [
|
||||
{ id: 'flights', name: 'Live Flights', enabled: true, stats: flights },
|
||||
{
|
||||
id: 'ais',
|
||||
name: 'Live AIS Vessels',
|
||||
enabled: true,
|
||||
stats: { loading: true, count: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
await hud._updateSummary(false, true);
|
||||
assert.match(shown(), /РЕЙСЫ ОНЛАЙН/, shown());
|
||||
// Live Flights finishes with nothing to show; AIS is still loading.
|
||||
flights = { count: 0 };
|
||||
hud._markSummaryDirty();
|
||||
await hud._updateSummary(false);
|
||||
assert.match(
|
||||
shown(),
|
||||
/^ЗАГРУЗКА \| Остин небо над городом \| СУДА AIS/,
|
||||
shown(),
|
||||
);
|
||||
assert.doesNotMatch(shown(), /РЕЙСЫ ОНЛАЙН/, shown());
|
||||
});
|
||||
|
||||
test('with every feed healthy a reply calling one unavailable falls back', async (t) => {
|
||||
const { hud, shown } = createHud(
|
||||
t,
|
||||
{ summary: 'Остин рейсы недоступно сейчас', feedState: null },
|
||||
{ layers: () => [] },
|
||||
);
|
||||
await hud._updateSummary(false, true);
|
||||
assert.equal(shown(), hud._composeSummary());
|
||||
});
|
||||
|
||||
for (const [locale, place, summary] of [
|
||||
['zh-CN', '北京', '北京航班不可用'],
|
||||
['zh-Hant', '北京', '北京航班不可用'],
|
||||
['ja', '東京', '東京航空便利用不可'],
|
||||
]) {
|
||||
test(`a ${locale} reply calling a feed unavailable flush against Han falls back`, async (t) => {
|
||||
const { hud, shown } = createHud(
|
||||
t,
|
||||
{ summary, feedState: null },
|
||||
{ locale, places: [place], layers: () => [] },
|
||||
);
|
||||
await hud._updateSummary(false, true);
|
||||
assert.equal(shown(), hud._composeSummary());
|
||||
});
|
||||
}
|
||||
|
||||
test('an ar reply opening with a Latin name keeps reading right to left', async (t) => {
|
||||
const { hud, shown } = createHud(
|
||||
t,
|
||||
{ summary: 'Times Square نيويورك الولايات المتحدة', feedState: null },
|
||||
{ locale: 'ar', places: ['Times Square'], layers: () => [] },
|
||||
);
|
||||
await hud._updateSummary(false, true);
|
||||
assert.equal(shown(), '\u200fTimes Square نيويورك الولايات المتحدة');
|
||||
});
|
||||
|
||||
test('a one-word layer name that is a French word is not an echo', async (t) => {
|
||||
const summary = 'Bus en transit à Paris';
|
||||
const { hud, shown } = createHud(
|
||||
t,
|
||||
{ summary, feedState: null },
|
||||
{
|
||||
locale: 'fr',
|
||||
places: ['Paris'],
|
||||
layers: () => [{ id: 'transit', name: 'Transit', enabled: true }],
|
||||
},
|
||||
);
|
||||
await hud._updateSummary(false, true);
|
||||
assert.equal(shown(), summary);
|
||||
});
|
||||
+181
-27
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
SNAPSHOT_FEED_STATES,
|
||||
feedProvenanceEnvelope,
|
||||
layerSnapshots,
|
||||
provenanceTag,
|
||||
} from './data/layerSnapshot.js';
|
||||
import { DEFAULT_LOCALE, normalizeLocale } from './i18n/locale.js';
|
||||
|
||||
export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED';
|
||||
|
||||
@@ -43,19 +46,189 @@ export function isHudSummaryUnconfigured(status, data) {
|
||||
}
|
||||
|
||||
/**
|
||||
* HUD summary model instructions. The five-word line may only describe
|
||||
* supplied labels, and must carry feed-state when any enabled layer is not
|
||||
* nominal.
|
||||
* HUD summary model instructions. The five words may only describe supplied
|
||||
* labels; the feed state comes back as its own canonical field, so the check
|
||||
* does not depend on the summary's language or wording.
|
||||
*/
|
||||
export const HUD_SUMMARY_INSTRUCTIONS = [
|
||||
"Write one concise intelligence-HUD summary for God's Eye View.",
|
||||
'Use only the supplied place, street, nearby-place, enabled-layer labels, and feedProvenance.',
|
||||
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
|
||||
'If any enabled layer is not nominal, the five words MUST include that feedState token (STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE) and must not present the view as live.',
|
||||
'In the summary, name layers only as in enabledLayerLabels; never copy layer names or state words from feedProvenance into it.',
|
||||
'Set feedState to feedProvenance.overall exactly as given, or null when it is null.',
|
||||
'When feedProvenance.overall is null, say nothing about feeds or layers, present or missing.',
|
||||
'If feedProvenance.overall is partial, do not present the coverage as complete; if it is any other state but nominal, do not present the view as live or healthy.',
|
||||
'Do not infer from coordinates or invent a place or feed state.',
|
||||
'Output exactly five words with no title, punctuation, markdown, or introductory phrase.',
|
||||
// "Exactly five words" made a small model pad a short view with words like
|
||||
// "no data" or "unavailable".
|
||||
'The summary has at most five words, with no title, punctuation, markdown, or introductory phrase.',
|
||||
].join(' ');
|
||||
|
||||
/** Longest summary, in characters, for a script written without spaces. */
|
||||
export const HUD_SUMMARY_MAX_UNSPACED_CHARS = 20;
|
||||
|
||||
/** Longest summary, in characters; the cut falls between words. */
|
||||
export const HUD_SUMMARY_MAX_CHARS = 80;
|
||||
|
||||
/** Responses API structured output: the summary plus the canonical state. */
|
||||
export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({
|
||||
type: 'json_schema',
|
||||
name: 'hud_summary',
|
||||
strict: true,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
summary: { type: 'string' },
|
||||
// Nullable rather than a `none` token, which the model wrote into the
|
||||
// summary of a view without layers.
|
||||
feedState: {
|
||||
anyOf: [
|
||||
{ type: 'string', enum: [...SNAPSHOT_FEED_STATES] },
|
||||
{ type: 'null' },
|
||||
],
|
||||
},
|
||||
},
|
||||
required: ['summary', 'feedState'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
});
|
||||
|
||||
/** A shipped locale for the request, English when missing or unsupported. */
|
||||
export function hudSummaryLocale(value) {
|
||||
return normalizeLocale(value) || DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
// Names Intl.DisplayNames gets wrong for the shipped catalogs: pt is the
|
||||
// Brazilian variant, and zh-Hant is the zh-CN wording in Traditional script.
|
||||
const SUMMARY_LANGUAGES = Object.freeze({
|
||||
pt: 'Brazilian Portuguese',
|
||||
'zh-CN': 'Simplified Chinese',
|
||||
'zh-Hant': 'Chinese in Traditional characters (mainland wording)',
|
||||
});
|
||||
|
||||
/** Scripts written without spaces, capped in characters instead of words. */
|
||||
const UNSPACED_LOCALES = /^(?:ja|zh)(?:-|$)/;
|
||||
|
||||
/** Shipped locales not written in Latin letters. */
|
||||
const NON_LATIN_LOCALES = /^(?:ar|ja|ru|uk|zh)(?:-|$)/;
|
||||
|
||||
/** Scripts whose letters extend a word; any other neighbour ends it. */
|
||||
const WORD_SCRIPTS = [
|
||||
'Latin',
|
||||
'Cyrillic',
|
||||
'Greek',
|
||||
'Arabic',
|
||||
'Hebrew',
|
||||
'Han',
|
||||
'Hiragana',
|
||||
'Katakana',
|
||||
'Hangul',
|
||||
].map((script) => `\\p{Script=${script}}`);
|
||||
|
||||
/** Lower case, with punctuation and brackets as spaces: `Earthquakes 24h`. */
|
||||
function flatWords(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFC')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const UNSPACED_WORD =
|
||||
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ー]+$/u;
|
||||
|
||||
function wordScript(char) {
|
||||
return WORD_SCRIPTS.find((script) => new RegExp(script, 'u').test(char));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `word` stands on its own in `text`. Only a letter of the same
|
||||
* script extends it, so `stale` flush against kana or Han still counts.
|
||||
* Han and kana have no word edges: a word in them counts anywhere.
|
||||
* @param {string} text
|
||||
* @param {string} word
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasWord(text, word) {
|
||||
const haystack = flatWords(text);
|
||||
const needle = flatWords(word);
|
||||
if (!needle) return false;
|
||||
if (UNSPACED_WORD.test(needle)) return haystack.includes(needle);
|
||||
const chars = [...needle];
|
||||
const before = wordScript(chars[0]);
|
||||
const after = wordScript(chars.at(-1));
|
||||
// flatWords leaves no regex syntax in the needle.
|
||||
return new RegExp(
|
||||
`${before ? `(?<!${before})` : ''}${needle}${after ? `(?!${after})` : ''}`,
|
||||
'u',
|
||||
).test(haystack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a non-English summary carries what the HUD does not show: one of
|
||||
* `words` (feed-state ids, translated layers' English names and ids), or only
|
||||
* Latin letters in a language written in another script.
|
||||
* @param {string} summary The model's words.
|
||||
* @param {unknown} locale The request locale.
|
||||
* @param {string[]} [words] Words the summary must not carry.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hudSummaryHasEnglish(summary, locale, words = []) {
|
||||
const tag = hudSummaryLocale(locale);
|
||||
if (tag === DEFAULT_LOCALE) return false;
|
||||
const letters = String(summary || '').replace(/[^\p{L}]/gu, '');
|
||||
if (!hudSummaryIsLatin(tag) && !letters.replace(/\p{Script=Latin}/gu, ''))
|
||||
return true;
|
||||
return words.some((word) => hasWord(summary, word));
|
||||
}
|
||||
|
||||
/** Whether the locale is written in Latin letters. */
|
||||
export function hudSummaryIsLatin(locale) {
|
||||
return !NON_LATIN_LOCALES.test(hudSummaryLocale(locale));
|
||||
}
|
||||
|
||||
export function hudSummaryIsUnspaced(locale) {
|
||||
return UNSPACED_LOCALES.test(hudSummaryLocale(locale));
|
||||
}
|
||||
|
||||
/** The language a request's summary is written in, named in English. */
|
||||
function summaryLanguage(tag) {
|
||||
if (SUMMARY_LANGUAGES[tag]) return SUMMARY_LANGUAGES[tag];
|
||||
try {
|
||||
return new Intl.DisplayNames(['en'], { type: 'language' }).of(tag) || tag;
|
||||
} catch {
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} locale The `locale` the HUD sent.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function hudSummaryInstructions(locale) {
|
||||
const tag = hudSummaryLocale(locale);
|
||||
if (tag === DEFAULT_LOCALE) return HUD_SUMMARY_INSTRUCTIONS;
|
||||
const language = summaryLanguage(tag);
|
||||
const length = hudSummaryIsUnspaced(tag)
|
||||
? ` Instead of five words, write one phrase of at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters.`
|
||||
: '';
|
||||
return `${HUD_SUMMARY_INSTRUCTIONS} Write the summary in ${language} (${tag}), not English. Give places their usual names in that language and layers the names in enabledLayerLabels; feedState stays the English value.${length}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model input: the HUD context, led by the summary language when it is
|
||||
* not English. A small model follows the language far more reliably when it
|
||||
* is part of the input as well as the instructions.
|
||||
* @param {object} context The HUD summary context.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function hudSummaryInput(context) {
|
||||
const tag = hudSummaryLocale(context?.locale);
|
||||
const json = JSON.stringify(context ?? {});
|
||||
if (tag === DEFAULT_LOCALE) return json;
|
||||
return `Summary language: ${summaryLanguage(tag)} (${tag}).\n${json}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp HUD summary context with the same layer snapshots voice tools use.
|
||||
* @param {Array<object>} [layers] `DataLayerManager.getAll()` rows.
|
||||
@@ -83,32 +256,13 @@ export function hudSummaryLayerContext(layers = [], options) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The feed state and up to two layer names for the HUD telemetry suffix when
|
||||
* an enabled feed is not nominal. Values stay English (`state` is a feed-state
|
||||
* id, `names` are `layer.name`); the HUD translates them for display.
|
||||
* The warning for the HUD telemetry line when an enabled feed is not
|
||||
* nominal.
|
||||
* @param {Array<object>} [layers]
|
||||
* @param {{ now?: number }} [options]
|
||||
* @returns {{ state: string, names: string[] }|null}
|
||||
*/
|
||||
export function hudTelemetryProvenance(layers = [], options) {
|
||||
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
|
||||
const envelope = feedProvenanceEnvelope(snapshots, options);
|
||||
if (!envelope.overall || envelope.overall === 'nominal') return null;
|
||||
const names = envelope.layers
|
||||
.filter(
|
||||
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
||||
)
|
||||
.map((s) => String(s.name || s.id || 'LAYER'))
|
||||
.slice(0, 2);
|
||||
return { state: envelope.overall, names };
|
||||
}
|
||||
|
||||
/** Require the supplied non-nominal state before showing an AI summary. */
|
||||
export function hudSummaryMatchesProvenance(summary, provenance) {
|
||||
const state = provenance?.overall;
|
||||
if (!state || state === 'nominal' || state === 'off') return true;
|
||||
return String(summary || '')
|
||||
.toUpperCase()
|
||||
.split(/\W+/)
|
||||
.includes(String(state).toUpperCase());
|
||||
return provenanceTag(feedProvenanceEnvelope(snapshots, options));
|
||||
}
|
||||
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user