Merge branch 'main' into manjunath/hal-769-range-ring-visibility

# Conflicts:
#	scripts/format-scope.json
This commit is contained in:
Sameh Khamis committed 2026-11-03 10:33:19 -08:00
commit 0d32f59b40
117 files changed
+16480 -1005

No files matched your search

+1
View File
@@ -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
+1
View File
@@ -193,6 +193,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.
+2 -1
View File
@@ -445,6 +445,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
| 🚌 **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 | 🟢 |
@@ -454,7 +455,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
**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
View File
@@ -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 map routes (`/api/terrain/heights` and `/api/overpass`) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map needs both to render behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless 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.
+5
View File
@@ -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).
+15
View File
@@ -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).
+30 -7
View File
@@ -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
@@ -697,8 +718,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.
@@ -828,7 +850,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
@@ -896,10 +920,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
+1 -1
View File
@@ -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
+112
View File
@@ -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",
@@ -1111,6 +1114,115 @@
"src/cameraGlobeRotation.test.mjs",
"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",
"src/voice/voiceControlView.test.mjs",
"src/voice/panel/host.test.mjs",
"src/voice/panel/manualPanel.test.mjs",
"src/voice/panel/nativeActivity.test.mjs",
"src/voice/panel/inactivity.test.mjs",
"src/voice/panel/actionObserver.test.mjs",
"src/voice/realtimeCostPanel.test.mjs",
"docs/SHARE-LINK-FORMAT.md",
"src/share/wire.test.mjs",
"src/share/viewCodec.test.mjs",
"src/share/legacyShareState.test.mjs",
"src/testSupport/shareViewFixtures.mjs",
"src/ui/shareLinks.test.mjs",
"src/ui/shareButton.test.mjs",
"src/share/shareLinkPath.test.mjs",
"src/ui/kit/UiRail.test.mjs",
"src/ui/kit/UiSegmented.vue",
"src/ui/kit/UiSegmented.test.mjs",
"src/ui/kit/UiSlider.js",
"src/ui/kit/UiSlider.test.mjs",
"src/ui/kit/UiButton.vue",
"src/ui/kit/UiButton.test.mjs",
"src/ui/kit/UiChip.vue",
"src/ui/kit/UiChip.test.mjs",
"src/ui/globeActions.test.mjs",
"src/ui/safeFrameView.test.mjs",
"src/ui/kit/UiSelect.js",
"src/ui/kit/UiSelect.test.mjs",
"src/ui/kit/UiBadge.vue",
"src/ui/kit/UiBadge.test.mjs",
"src/ui/kit/UiEmpty.vue",
"src/ui/rail/RailChips.vue",
"src/ui/displayPanelActions.test.mjs",
"src/testSupport/vueDom.mjs",
"build/vueLoader.js",
"build/registerVue.js",
"src/ui/panelNavigation.test.mjs",
"src/ui/stores/owners.js",
"src/ui/stores/owners.test.mjs",
"src/ui/stores/panels.test.mjs",
"src/ui/stores/status.js",
"src/ui/statusChipsView.test.mjs",
"src/ui/statusChipOwners.test.mjs",
"src/ui/toast.js",
"src/ui/toastView.test.mjs",
"src/ui/stores/sceneInteractions.js",
"src/scenes/dataPacks/presentationView.test.mjs",
"src/ui/stores/dataPacks.js",
"src/scenes/interactionsView.test.mjs",
"src/layers/wind/inspectionMarker.js",
"src/layers/wind/inspectionMarker.test.mjs",
"src/ui/stores/windInspection.js",
"src/ui/stores/missionReplay.js",
"src/layers/launches/replayOverlay.test.mjs",
"src/ui/stores/bhoteKoshiPanel.js",
"src/data/bhoteKoshiPanelView.test.mjs",
"src/ui/stores/celestialRing.js",
"src/celestialRingView.test.mjs",
"src/ui/stores/screenAnnotations.js",
"src/ui/stores/worldOverlayActions.js",
"src/overlays/worldOverlayActionsView.test.mjs",
"src/ui/stores/awarenessDirection.js",
"src/layers/awareness/directionOverlay.test.mjs",
"src/hudReadouts.js",
"src/ui/stores/hud.js",
"src/ui/views/intelHudView.test.mjs",
"src/testSupport/hudReadouts.mjs",
"src/ui/stores/firstRun.js",
"src/firstRunLauncherView.test.mjs",
"src/firstRunCopy.js",
"src/data/headingDegrees.test.mjs",
"src/data/distanceFormat.test.mjs",
"src/sources/adsbFields.test.mjs",
"src/sources/phaseTiming.test.mjs",
"scripts/build-census-places.mjs",
"src/data/placeBoundaries.test.mjs",
"src/data/boundaryLabels.test.mjs",
"src/data/packGeometry.test.mjs",
"src/annotations/placeOutline.test.mjs",
"src/annotations/outlineIdentity.test.mjs",
"src/annotations/outlineRungs.test.mjs",
"src/annotations/outlineRungsFallback.test.mjs",
"src/search/photonSetting.test.mjs",
"src/sources/nominatimOutlines.test.mjs",
"src/sources/openFreeMapOutlines.test.mjs",
"src/sources/openFreeMapOutlinesEdges.test.mjs",
"src/standalone/layerSources.test.mjs",
"src/tooling/nominatimGuardLifecycle.test.mjs",
"src/tooling/nominatimLockOwnership.test.mjs",
"src/tooling/nominatimOutlineRoute.test.mjs",
"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"
]
+28 -4
View File
@@ -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": []
},
@@ -508,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": []
},
@@ -2274,6 +2280,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",
@@ -3184,7 +3191,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",
@@ -3792,7 +3812,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",
@@ -4157,6 +4180,7 @@
"exports": ["./voice/action-schemas"],
"modules": [
"src/voice/actionSchemas.js",
"src/voice/areaCandidateContract.js",
"src/voice/layerManifest.js",
"src/hudLayoutPolicy.js"
],
+216 -4
View File
@@ -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 {
+333
View File
@@ -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();
}
+50 -15
View File
@@ -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'],
+137
View File
@@ -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;
},
};
}
+8 -4
View File
@@ -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,
+4
View File
@@ -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
+15 -5
View File
@@ -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.',
+7 -3
View File
@@ -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',
},
};
+42 -3
View File
@@ -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: {
+25
View File
@@ -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 });
}
+2
View File
@@ -28,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();
@@ -274,6 +275,7 @@ function overpassProxy({ routing = {} } = {}) {
});
installRouteMiddleware(server.middlewares, routing);
installOsmFeaturesRoute(server.middlewares);
};
return {
name: 'overpass-proxy',
+205
View File
@@ -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);
}
});
}
+13 -3
View File
@@ -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 });
@@ -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,
},
});
+62 -4
View File
@@ -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',
};
}
+290 -80
View File
@@ -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();
});
+5
View File
@@ -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(
+37 -2
View File
@@ -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 };
+40
View File
@@ -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 [
+6
View File
@@ -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: {
+4
View File
@@ -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';
@@ -42,6 +43,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. */
@@ -145,6 +148,7 @@ export function createApplicationCatalog({
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections({ source: sources.directions }),
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
createApplicationOsmPlaces(),
vessels,
installations,
createApplicationAwareness({
+7 -2
View File
@@ -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, 35);
assert.equal(first.layers.length, 36);
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' },
@@ -155,7 +160,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
surface: fixtureSurface(lifetime.signal),
};
const catalog = createApplicationCatalog(options);
assert.equal(catalog.layers.length, 35);
assert.equal(catalog.layers.length, 36);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
+7
View File
@@ -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.
+68
View File
@@ -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);
});
+7
View File
@@ -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 });
}
+1 -1
View File
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
assert.equal(actual.layers.length, 35);
assert.equal(actual.layers.length, 36);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
+241
View File
@@ -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;
};
}
+764
View File
@@ -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();
}
});
+72
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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;
},
+282 -5
View File
@@ -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`);
});
+397
View File
@@ -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;
}
+178
View File
@@ -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}`);
});
+668
View File
@@ -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,
};
}
+896
View File
@@ -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',
);
});
+212
View File
@@ -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 } : {}),
};
}
+9
View File
@@ -572,6 +572,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).
+60 -5
View File
@@ -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;
}
+18
View File
@@ -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);
});
+426
View File
@@ -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;
}
+11 -4
View File
@@ -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;
@@ -1031,11 +1037,12 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// S2 GhostMaps extends the generated layer enums; both feature contracts remain.
// The layer alias hint leaves out aliases the layer id already spells.
// The Utilities panel adds one set_panel_open target; Sea Marks and Ski
// Pistes extend the generated layer enums and alias hint.
assert.equal(block.length, 31026, 'serialized tool schema length drifted');
// 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'),
'5f5275cd6e320afef4fd224b235f3e0f191ec06599b257ffe1f60b703a255199',
'f5b42f41c2f575838f979e88a1dbff615a77ccd85fdf8fcd0a0a47d02f7d3ddc',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(
+5 -2
View File
@@ -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
View File
@@ -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),
+82
View File
@@ -3223,6 +3223,88 @@
"UTILITIES": "الأدوات",
"Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم",
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
"Voice place search (needs an Overpass server)": "البحث الصوتي عن الأماكن (يتطلب خادم Overpass)",
"OSM Places": "أماكن OSM",
"state or province": "ولاية أو مقاطعة",
"county or district": "محافظة أو قضاء",
"neighbourhood": "حي",
"natural region": "منطقة طبيعية",
"Which {place}?": "أي {place} تقصد؟",
"Which place?": "أي مكان تقصد؟",
"Not drawn: {parts}": "لم يُرسم: {parts}",
"approximate": "تقريبي",
"{pct}% cloud": "غيوم {pct}%",
"Over {area}": "فوق {area}",
"{count} of {total} acquisitions match": {
"few": "الصور المطابقة: {count} من {total}",
"many": "الصور المطابقة: {count} من {total}",
"one": "الصور المطابقة: {count} من {total}",
"two": "الصور المطابقة: {count} من {total}",
"zero": "الصور المطابقة: {count} من {total}",
"other": "الصور المطابقة: {count} من {total}"
},
"{n} acquisitions searched": {
"few": "الصور التي جرى البحث فيها: {n}",
"many": "الصور التي جرى البحث فيها: {n}",
"one": "الصور التي جرى البحث فيها: {n}",
"two": "الصور التي جرى البحث فيها: {n}",
"zero": "الصور التي جرى البحث فيها: {n}",
"other": "الصور التي جرى البحث فيها: {n}"
},
"No matching imagery": "لا توجد صور مطابقة",
"Unknown number of {kind}": "{kind}: العدد غير معروف",
"About {count} {kind}": "{kind}: نحو {count}",
"At least {count} {kind}": "{kind}: {count} على الأقل",
"{count} {kind}": "{kind}: {count}",
"In {area}": "في {area}",
"Area around {place}": "المنطقة المحيطة بـ{place}",
"Area around landmark": "المنطقة المحيطة بمعلم",
"Outline {place}": "تحديد مخطط {place}",
"Outline area": "تحديد مخطط منطقة",
"Find {place}": "البحث عن {place}",
"Find area": "البحث عن منطقة",
"Find recent imagery": "البحث عن صور حديثة",
"Find {what}": "البحث عن {what}",
"Find places": "البحث عن أماكن",
"Drawing outline": "جارٍ رسم المخطط",
"Searching imagery catalog": "جارٍ البحث في فهرس الصور",
"Loading imagery": "جارٍ تحميل الصور",
"Searching OpenStreetMap": "جارٍ البحث في OpenStreetMap",
"voice area|Area": "منطقة",
"buffer sized to the place": "منطقة تقريبية بحجم المكان",
"enclosing mapped area": "المنطقة المرسومة المحيطة",
"the map was cleared while looking it up": "مُسحت الخريطة أثناء البحث",
"OpenStreetMap coverage varies by place": "تختلف تغطية OpenStreetMap من مكان لآخر",
"the catalog covers the last 30 days": "يغطي الفهرس آخر 30 يومًا",
"there are more": "هناك المزيد",
"{radius} m around the landmark": "{radius} م حول المعلم",
"{count} parts": {
"few": "الأجزاء: {count}",
"many": "الأجزاء: {count}",
"one": "الأجزاء: {count}",
"other": "الأجزاء: {count}",
"two": "الأجزاء: {count}",
"zero": "الأجزاء: {count}"
},
"{drawn} of {total} parts drawn": {
"few": "الأجزاء المرسومة: {drawn} من {total}",
"many": "الأجزاء المرسومة: {drawn} من {total}",
"one": "الأجزاء المرسومة: {drawn} من {total}",
"other": "الأجزاء المرسومة: {drawn} من {total}",
"two": "الأجزاء المرسومة: {drawn} من {total}",
"zero": "الأجزاء المرسومة: {drawn} من {total}"
},
"{count} duplicate mappings merged": {
"few": "التكرارات المدمجة: {count}",
"many": "التكرارات المدمجة: {count}",
"one": "التكرارات المدمجة: {count}",
"other": "التكرارات المدمجة: {count}",
"two": "التكرارات المدمجة: {count}",
"zero": "التكرارات المدمجة: {count}"
},
"listed the first {limit}": "عُرض أول {limit}",
"counted over the bounding box of {area}, not its outline": "عُدّ ضمن المستطيل المحيط ({area})، لا ضمن الحدود",
"Search cancelled": "أُلغي البحث",
"Sea Marks": "علامات بحرية",
"Ski Pistes": "مسارات التزلج"
}
+62
View File
@@ -2943,6 +2943,68 @@
"UTILITIES": "WERKZEUGE",
"Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen",
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien",
"Voice place search (needs an Overpass server)": "Sprachgesteuerte Ortssuche (benötigt einen Overpass-Server)",
"OSM Places": "OSM-Orte",
"state or province": "Bundesstaat oder Provinz",
"county or district": "Landkreis oder Bezirk",
"neighbourhood": "Stadtviertel",
"natural region": "Naturraum",
"Which {place}?": "Welcher Ort ist mit {place} gemeint?",
"Which place?": "Welcher Ort?",
"Not drawn: {parts}": "Nicht gezeichnet: {parts}",
"approximate": "ungefähr",
"{pct}% cloud": "{pct} % Bewölkung",
"Over {area}": "Gebiet: {area}",
"{count} of {total} acquisitions match": {
"one": "Passende Aufnahmen: {count} von {total}",
"other": "Passende Aufnahmen: {count} von {total}"
},
"{n} acquisitions searched": {
"one": "Durchsuchte Aufnahmen: {n}",
"other": "Durchsuchte Aufnahmen: {n}"
},
"No matching imagery": "Keine passenden Bilddaten",
"Unknown number of {kind}": "{kind}: Anzahl unbekannt",
"About {count} {kind}": "{kind}: etwa {count}",
"At least {count} {kind}": "{kind}: mindestens {count}",
"{count} {kind}": "{kind}: {count}",
"In {area}": "Gebiet: {area}",
"Area around {place}": "Gebiet um {place}",
"Area around landmark": "Gebiet um ein Wahrzeichen",
"Outline {place}": "{place} umreißen",
"Outline area": "Gebiet umreißen",
"Find {place}": "{place} finden",
"Find area": "Gebiet finden",
"Find recent imagery": "Aktuelle Bilddaten finden",
"Find {what}": "{what} finden",
"Find places": "Orte finden",
"Drawing outline": "Umriss wird gezeichnet",
"Searching imagery catalog": "Bildkatalog wird durchsucht",
"Loading imagery": "Bilddaten werden geladen",
"Searching OpenStreetMap": "OpenStreetMap wird durchsucht",
"voice area|Area": "Gebiet",
"buffer sized to the place": "Puffer in Größe des Ortes",
"enclosing mapped area": "umschließendes kartiertes Gebiet",
"the map was cleared while looking it up": "die Karte wurde während der Suche geleert",
"OpenStreetMap coverage varies by place": "Die Abdeckung von OpenStreetMap ist je nach Ort unterschiedlich",
"the catalog covers the last 30 days": "der Katalog umfasst die letzten 30 Tage",
"there are more": "es gibt weitere",
"{radius} m around the landmark": "{radius} m um das Wahrzeichen",
"{count} parts": {
"one": "Teile: {count}",
"other": "Teile: {count}"
},
"{drawn} of {total} parts drawn": {
"one": "Gezeichnete Teile: {drawn} von {total}",
"other": "Gezeichnete Teile: {drawn} von {total}"
},
"{count} duplicate mappings merged": {
"one": "Zusammengeführte Doppelkartierungen: {count}",
"other": "Zusammengeführte Doppelkartierungen: {count}"
},
"listed the first {limit}": "die ersten {limit} aufgelistet",
"counted over the bounding box of {area}, not its outline": "über das umgebende Rechteck gezählt ({area}), nicht über den Umriss",
"Search cancelled": "Suche abgebrochen",
"Sea Marks": "Seezeichen",
"Ski Pistes": "Skipisten"
}
+67
View File
@@ -3013,6 +3013,73 @@
"UTILITIES": "UTILIDADES",
"Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo",
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos",
"Voice place search (needs an Overpass server)": "Búsqueda de lugares por voz (requiere un servidor Overpass)",
"OSM Places": "Lugares OSM",
"state or province": "estado o provincia",
"county or district": "condado o distrito",
"neighbourhood": "barrio",
"natural region": "región natural",
"Which {place}?": "¿Cuál {place}?",
"Which place?": "¿Qué lugar?",
"Not drawn: {parts}": "Sin dibujar: {parts}",
"approximate": "aproximado",
"{pct}% cloud": "{pct} % de nubes",
"Over {area}": "Zona: {area}",
"{count} of {total} acquisitions match": {
"many": "Adquisiciones coincidentes: {count} de {total}",
"one": "Adquisiciones coincidentes: {count} de {total}",
"other": "Adquisiciones coincidentes: {count} de {total}"
},
"{n} acquisitions searched": {
"many": "Adquisiciones revisadas: {n}",
"one": "Adquisiciones revisadas: {n}",
"other": "Adquisiciones revisadas: {n}"
},
"No matching imagery": "No hay imágenes que coincidan",
"Unknown number of {kind}": "{kind}: número desconocido",
"About {count} {kind}": "{kind}: unos {count}",
"At least {count} {kind}": "{kind}: al menos {count}",
"{count} {kind}": "{kind}: {count}",
"In {area}": "En {area}",
"Area around {place}": "Área alrededor de {place}",
"Area around landmark": "Área alrededor de un punto de interés",
"Outline {place}": "Trazar contorno de {place}",
"Outline area": "Trazar contorno de área",
"Find {place}": "Buscar {place}",
"Find area": "Buscar área",
"Find recent imagery": "Buscar imágenes recientes",
"Find {what}": "Buscar {what}",
"Find places": "Buscar lugares",
"Drawing outline": "Dibujando contorno",
"Searching imagery catalog": "Buscando en el catálogo de imágenes",
"Loading imagery": "Cargando imágenes",
"Searching OpenStreetMap": "Buscando en OpenStreetMap",
"voice area|Area": "Área",
"buffer sized to the place": "zona aproximada del tamaño del lugar",
"enclosing mapped area": "zona cartografiada que lo rodea",
"the map was cleared while looking it up": "el mapa se borró durante la búsqueda",
"OpenStreetMap coverage varies by place": "La cobertura de OpenStreetMap varía según el lugar",
"the catalog covers the last 30 days": "el catálogo cubre los últimos 30 días",
"there are more": "hay más",
"{radius} m around the landmark": "{radius} m alrededor del lugar",
"{count} parts": {
"many": "Partes: {count}",
"one": "Partes: {count}",
"other": "Partes: {count}"
},
"{drawn} of {total} parts drawn": {
"many": "Partes dibujadas: {drawn} de {total}",
"one": "Partes dibujadas: {drawn} de {total}",
"other": "Partes dibujadas: {drawn} de {total}"
},
"{count} duplicate mappings merged": {
"many": "Duplicados fusionados: {count}",
"one": "Duplicados fusionados: {count}",
"other": "Duplicados fusionados: {count}"
},
"listed the first {limit}": "se listaron los primeros {limit}",
"counted over the bounding box of {area}, not its outline": "contado en el rectángulo que lo rodea ({area}), no en su contorno",
"Search cancelled": "Búsqueda cancelada",
"Sea Marks": "Señales marítimas",
"Ski Pistes": "Pistas de esquí"
}
+67
View File
@@ -3013,6 +3013,73 @@
"UTILITIES": "OUTILS",
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
"Voice place search (needs an Overpass server)": "Recherche de lieux par la voix (nécessite un serveur Overpass)",
"OSM Places": "Lieux OSM",
"state or province": "État ou province",
"county or district": "comté ou district",
"neighbourhood": "quartier",
"natural region": "région naturelle",
"Which {place}?": "Quel lieu nommé {place} ?",
"Which place?": "Quel lieu ?",
"Not drawn: {parts}": "Non dessiné : {parts}",
"approximate": "approximatif",
"{pct}% cloud": "{pct} % de nuages",
"Over {area}": "Zone : {area}",
"{count} of {total} acquisitions match": {
"many": "Acquisitions correspondantes : {count} sur {total}",
"one": "Acquisitions correspondantes : {count} sur {total}",
"other": "Acquisitions correspondantes : {count} sur {total}"
},
"{n} acquisitions searched": {
"many": "Acquisitions examinées : {n}",
"one": "Acquisitions examinées : {n}",
"other": "Acquisitions examinées : {n}"
},
"No matching imagery": "Aucune image correspondante",
"Unknown number of {kind}": "{kind} : nombre inconnu",
"About {count} {kind}": "{kind} : environ {count}",
"At least {count} {kind}": "{kind} : au moins {count}",
"{count} {kind}": "{kind} : {count}",
"In {area}": "Zone : {area}",
"Area around {place}": "Environs : {place}",
"Area around landmark": "Environs d'un repère",
"Outline {place}": "Délimiter {place}",
"Outline area": "Délimiter une zone",
"Find {place}": "Trouver {place}",
"Find area": "Trouver une zone",
"Find recent imagery": "Trouver des images récentes",
"Find {what}": "Trouver {what}",
"Find places": "Trouver des lieux",
"Drawing outline": "Dessin du contour",
"Searching imagery catalog": "Recherche dans le catalogue d'images",
"Loading imagery": "Chargement des images",
"Searching OpenStreetMap": "Recherche dans OpenStreetMap",
"voice area|Area": "Zone",
"buffer sized to the place": "zone tampon à la taille du lieu",
"enclosing mapped area": "zone cartographiée englobante",
"the map was cleared while looking it up": "la carte a été effacée pendant la recherche",
"OpenStreetMap coverage varies by place": "La couverture OpenStreetMap varie selon les lieux",
"the catalog covers the last 30 days": "le catalogue couvre les 30 derniers jours",
"there are more": "il y en a d'autres",
"{radius} m around the landmark": "{radius} m autour du lieu",
"{count} parts": {
"many": "Parties : {count}",
"one": "Parties : {count}",
"other": "Parties : {count}"
},
"{drawn} of {total} parts drawn": {
"many": "Parties tracées : {drawn} sur {total}",
"one": "Parties tracées : {drawn} sur {total}",
"other": "Parties tracées : {drawn} sur {total}"
},
"{count} duplicate mappings merged": {
"many": "Doublons fusionnés : {count}",
"one": "Doublons fusionnés : {count}",
"other": "Doublons fusionnés : {count}"
},
"listed the first {limit}": "les {limit} premiers sont listés",
"counted over the bounding box of {area}, not its outline": "compté sur le rectangle englobant ({area}), pas sur le contour",
"Search cancelled": "Recherche annulée",
"Sea Marks": "Balisage maritime",
"Ski Pistes": "Pistes de ski"
}
+67
View File
@@ -3013,6 +3013,73 @@
"UTILITIES": "STRUMENTI",
"Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno",
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file",
"Voice place search (needs an Overpass server)": "Ricerca vocale di luoghi (richiede un server Overpass)",
"OSM Places": "Luoghi OSM",
"state or province": "stato o provincia",
"county or district": "contea o distretto",
"neighbourhood": "quartiere",
"natural region": "regione naturale",
"Which {place}?": "Quale {place}?",
"Which place?": "Quale luogo?",
"Not drawn: {parts}": "Non disegnati: {parts}",
"approximate": "approssimativo",
"{pct}% cloud": "{pct}% di nuvole",
"Over {area}": "Zona: {area}",
"{count} of {total} acquisitions match": {
"many": "Acquisizioni corrispondenti: {count} su {total}",
"one": "Acquisizioni corrispondenti: {count} su {total}",
"other": "Acquisizioni corrispondenti: {count} su {total}"
},
"{n} acquisitions searched": {
"many": "Acquisizioni esaminate: {n}",
"one": "Acquisizioni esaminate: {n}",
"other": "Acquisizioni esaminate: {n}"
},
"No matching imagery": "Nessuna immagine corrispondente",
"Unknown number of {kind}": "{kind}: numero sconosciuto",
"About {count} {kind}": "{kind}: circa {count}",
"At least {count} {kind}": "{kind}: almeno {count}",
"{count} {kind}": "{kind}: {count}",
"In {area}": "Area: {area}",
"Area around {place}": "Area intorno a {place}",
"Area around landmark": "Area intorno a un punto di interesse",
"Outline {place}": "Traccia contorno di {place}",
"Outline area": "Traccia contorno area",
"Find {place}": "Trova {place}",
"Find area": "Trova area",
"Find recent imagery": "Trova immagini recenti",
"Find {what}": "Trova {what}",
"Find places": "Trova luoghi",
"Drawing outline": "Disegno del contorno",
"Searching imagery catalog": "Ricerca nel catalogo immagini",
"Loading imagery": "Caricamento immagini",
"Searching OpenStreetMap": "Ricerca in OpenStreetMap",
"voice area|Area": "Area",
"buffer sized to the place": "zona tampone delle dimensioni del luogo",
"enclosing mapped area": "area mappata circostante",
"the map was cleared while looking it up": "la mappa è stata cancellata durante la ricerca",
"OpenStreetMap coverage varies by place": "La copertura di OpenStreetMap varia da luogo a luogo",
"the catalog covers the last 30 days": "il catalogo copre gli ultimi 30 giorni",
"there are more": "ce ne sono altri",
"{radius} m around the landmark": "{radius} m attorno al luogo",
"{count} parts": {
"many": "Parti: {count}",
"one": "Parti: {count}",
"other": "Parti: {count}"
},
"{drawn} of {total} parts drawn": {
"many": "Parti disegnate: {drawn} su {total}",
"one": "Parti disegnate: {drawn} su {total}",
"other": "Parti disegnate: {drawn} su {total}"
},
"{count} duplicate mappings merged": {
"many": "Duplicati uniti: {count}",
"one": "Duplicati uniti: {count}",
"other": "Duplicati uniti: {count}"
},
"listed the first {limit}": "elencati i primi {limit}",
"counted over the bounding box of {area}, not its outline": "contato sul riquadro di delimitazione ({area}), non sul contorno",
"Search cancelled": "Ricerca annullata",
"Sea Marks": "Segnalamenti marittimi",
"Ski Pistes": "Piste da sci"
}
+57
View File
@@ -2873,6 +2873,63 @@
"UTILITIES": "ユーティリティ",
"Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください",
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
"Voice place search (needs an Overpass server)": "音声による場所検索(Overpassサーバーが必要)",
"OSM Places": "OSMの場所",
"state or province": "州・省",
"county or district": "郡・地区",
"neighbourhood": "近隣地区",
"natural region": "自然地域",
"Which {place}?": "どの{place}ですか?",
"Which place?": "どの場所ですか?",
"Not drawn: {parts}": "未描画:{parts}",
"approximate": "概算",
"{pct}% cloud": "雲量{pct}%",
"Over {area}": "{area}上空",
"{count} of {total} acquisitions match": {
"other": "一致した画像:{count}/{total}件"
},
"{n} acquisitions searched": {
"other": "検索した画像:{n}件"
},
"No matching imagery": "一致する画像はありません",
"Unknown number of {kind}": "{kind}:件数不明",
"About {count} {kind}": "{kind}:約{count}件",
"At least {count} {kind}": "{kind}:{count}件以上",
"{count} {kind}": "{kind}:{count}件",
"In {area}": "{area}内",
"Area around {place}": "{place}周辺のエリア",
"Area around landmark": "ランドマーク周辺のエリア",
"Outline {place}": "{place}の輪郭を描く",
"Outline area": "エリアの輪郭を描く",
"Find {place}": "{place}を検索",
"Find area": "エリアを検索",
"Find recent imagery": "最近の衛星画像を検索",
"Find {what}": "{what}を検索",
"Find places": "場所を検索",
"Drawing outline": "輪郭を作図中",
"Searching imagery catalog": "画像カタログを検索中",
"Loading imagery": "画像を読み込み中",
"Searching OpenStreetMap": "OpenStreetMapを検索中",
"voice area|Area": "エリア",
"buffer sized to the place": "場所の大きさに合わせたバッファ",
"enclosing mapped area": "周囲の地図上のエリア",
"the map was cleared while looking it up": "検索中に地図がクリアされました",
"OpenStreetMap coverage varies by place": "OpenStreetMap の網羅度は場所によって異なります",
"the catalog covers the last 30 days": "カタログは過去 30 日分です",
"there are more": "ほかにもあります",
"{radius} m around the landmark": "ランドマークの周囲 {radius} m",
"{count} parts": {
"other": "パーツ数: {count}"
},
"{drawn} of {total} parts drawn": {
"other": "描画したパーツ: {drawn}/{total}"
},
"{count} duplicate mappings merged": {
"other": "統合した重複: {count}"
},
"listed the first {limit}": "最初の {limit} 件を表示",
"counted over the bounding box of {area}, not its outline": "外接矩形 ({area}) で数えた結果で、輪郭ではありません",
"Search cancelled": "検索をキャンセルしました",
"Sea Marks": "航路標識",
"Ski Pistes": "スキーゲレンデ"
}
+67
View File
@@ -3013,6 +3013,73 @@
"UTILITIES": "UTILITÁRIOS",
"Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho",
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos",
"Voice place search (needs an Overpass server)": "Busca de lugares por voz (requer um servidor Overpass)",
"OSM Places": "Lugares OSM",
"state or province": "estado ou província",
"county or district": "condado ou distrito",
"neighbourhood": "bairro",
"natural region": "região natural",
"Which {place}?": "Qual {place}?",
"Which place?": "Qual lugar?",
"Not drawn: {parts}": "Não desenhado: {parts}",
"approximate": "aproximado",
"{pct}% cloud": "{pct}% de nuvens",
"Over {area}": "Área: {area}",
"{count} of {total} acquisitions match": {
"many": "Aquisições correspondentes: {count} de {total}",
"one": "Aquisições correspondentes: {count} de {total}",
"other": "Aquisições correspondentes: {count} de {total}"
},
"{n} acquisitions searched": {
"many": "Aquisições pesquisadas: {n}",
"one": "Aquisições pesquisadas: {n}",
"other": "Aquisições pesquisadas: {n}"
},
"No matching imagery": "Nenhuma imagem correspondente",
"Unknown number of {kind}": "{kind}: número desconhecido",
"About {count} {kind}": "{kind}: cerca de {count}",
"At least {count} {kind}": "{kind}: pelo menos {count}",
"{count} {kind}": "{kind}: {count}",
"In {area}": "Área: {area}",
"Area around {place}": "Área ao redor de {place}",
"Area around landmark": "Área ao redor de um ponto de referência",
"Outline {place}": "Traçar contorno de {place}",
"Outline area": "Traçar contorno da área",
"Find {place}": "Encontrar {place}",
"Find area": "Encontrar área",
"Find recent imagery": "Encontrar imagens recentes",
"Find {what}": "Encontrar {what}",
"Find places": "Encontrar lugares",
"Drawing outline": "Desenhando contorno",
"Searching imagery catalog": "Buscando no catálogo de imagens",
"Loading imagery": "Carregando imagens",
"Searching OpenStreetMap": "Buscando no OpenStreetMap",
"voice area|Area": "Área",
"buffer sized to the place": "zona de margem do tamanho do local",
"enclosing mapped area": "área mapeada envolvente",
"the map was cleared while looking it up": "o mapa foi limpo durante a busca",
"OpenStreetMap coverage varies by place": "A cobertura do OpenStreetMap varia de lugar para lugar",
"the catalog covers the last 30 days": "o catálogo cobre os últimos 30 dias",
"there are more": "há mais",
"{radius} m around the landmark": "{radius} m ao redor do local",
"{count} parts": {
"many": "Partes: {count}",
"one": "Partes: {count}",
"other": "Partes: {count}"
},
"{drawn} of {total} parts drawn": {
"many": "Partes desenhadas: {drawn} de {total}",
"one": "Partes desenhadas: {drawn} de {total}",
"other": "Partes desenhadas: {drawn} de {total}"
},
"{count} duplicate mappings merged": {
"many": "Duplicatas mescladas: {count}",
"one": "Duplicatas mescladas: {count}",
"other": "Duplicatas mescladas: {count}"
},
"listed the first {limit}": "listados os primeiros {limit}",
"counted over the bounding box of {area}, not its outline": "contado na caixa delimitadora ({area}), não no contorno",
"Search cancelled": "Busca cancelada",
"Sea Marks": "Sinais marítimos",
"Ski Pistes": "Pistas de esqui"
}
+72
View File
@@ -3083,6 +3083,78 @@
"UTILITIES": "ИНСТРУМЕНТЫ",
"Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку",
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
"Voice place search (needs an Overpass server)": "Голосовой поиск мест (нужен сервер Overpass)",
"OSM Places": "Места OSM",
"state or province": "штат или провинция",
"county or district": "округ или район",
"neighbourhood": "район города",
"natural region": "природный регион",
"Which {place}?": "Какой из вариантов «{place}»?",
"Which place?": "Какое место?",
"Not drawn: {parts}": "Не нарисовано: {parts}",
"approximate": "приблизительно",
"{pct}% cloud": "Облачность {pct}%",
"Over {area}": "Область: {area}",
"{count} of {total} acquisitions match": {
"few": "Подходящие снимки: {count} из {total}",
"many": "Подходящие снимки: {count} из {total}",
"one": "Подходящие снимки: {count} из {total}",
"other": "Подходящие снимки: {count} из {total}"
},
"{n} acquisitions searched": {
"few": "Просмотрено снимков: {n}",
"many": "Просмотрено снимков: {n}",
"one": "Просмотрено снимков: {n}",
"other": "Просмотрено снимков: {n}"
},
"No matching imagery": "Подходящих снимков нет",
"Unknown number of {kind}": "{kind}: количество неизвестно",
"About {count} {kind}": "{kind}: около {count}",
"At least {count} {kind}": "{kind}: не менее {count}",
"{count} {kind}": "{kind}: {count}",
"In {area}": "Территория: {area}",
"Area around {place}": "Окрестности: {place}",
"Area around landmark": "Окрестности ориентира",
"Outline {place}": "Контур: {place}",
"Outline area": "Контур области",
"Find {place}": "Поиск: {place}",
"Find area": "Найти область",
"Find recent imagery": "Найти свежие снимки",
"Find {what}": "Поиск: {what}",
"Find places": "Найти места",
"Drawing outline": "Рисование контура",
"Searching imagery catalog": "Поиск в каталоге снимков",
"Loading imagery": "Загрузка снимков",
"Searching OpenStreetMap": "Поиск в OpenStreetMap",
"voice area|Area": "Область",
"buffer sized to the place": "буферная зона по размеру места",
"enclosing mapped area": "охватывающая область на карте",
"the map was cleared while looking it up": "карта была очищена во время поиска",
"OpenStreetMap coverage varies by place": "Покрытие OpenStreetMap различается в разных местах",
"the catalog covers the last 30 days": "каталог охватывает последние 30 дней",
"there are more": "есть ещё",
"{radius} m around the landmark": "{radius} м вокруг объекта",
"{count} parts": {
"few": "Частей: {count}",
"many": "Частей: {count}",
"one": "Частей: {count}",
"other": "Частей: {count}"
},
"{drawn} of {total} parts drawn": {
"few": "Нарисовано частей: {drawn} из {total}",
"many": "Нарисовано частей: {drawn} из {total}",
"one": "Нарисовано частей: {drawn} из {total}",
"other": "Нарисовано частей: {drawn} из {total}"
},
"{count} duplicate mappings merged": {
"few": "Объединено дублей: {count}",
"many": "Объединено дублей: {count}",
"one": "Объединено дублей: {count}",
"other": "Объединено дублей: {count}"
},
"listed the first {limit}": "показаны первые {limit}",
"counted over the bounding box of {area}, not its outline": "подсчитано по ограничивающему прямоугольнику ({area}), а не по контуру",
"Search cancelled": "Поиск отменён",
"Sea Marks": "Морские знаки",
"Ski Pistes": "Горнолыжные трассы"
}
+72
View File
@@ -3083,6 +3083,78 @@
"UTILITIES": "ІНСТРУМЕНТИ",
"Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка",
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
"Voice place search (needs an Overpass server)": "Голосовий пошук місць (потрібен сервер Overpass)",
"OSM Places": "Місця OSM",
"state or province": "штат або провінція",
"county or district": "округ або район",
"neighbourhood": "район міста",
"natural region": "природний регіон",
"Which {place}?": "Який із варіантів «{place}»?",
"Which place?": "Яке місце?",
"Not drawn: {parts}": "Не намальовано: {parts}",
"approximate": "приблизно",
"{pct}% cloud": "Хмарність {pct}%",
"Over {area}": "Область: {area}",
"{count} of {total} acquisitions match": {
"few": "Відповідні знімки: {count} з {total}",
"many": "Відповідні знімки: {count} з {total}",
"one": "Відповідні знімки: {count} з {total}",
"other": "Відповідні знімки: {count} з {total}"
},
"{n} acquisitions searched": {
"few": "Переглянуто знімків: {n}",
"many": "Переглянуто знімків: {n}",
"one": "Переглянуто знімків: {n}",
"other": "Переглянуто знімків: {n}"
},
"No matching imagery": "Відповідних знімків немає",
"Unknown number of {kind}": "{kind}: кількість невідома",
"About {count} {kind}": "{kind}: близько {count}",
"At least {count} {kind}": "{kind}: щонайменше {count}",
"{count} {kind}": "{kind}: {count}",
"In {area}": "Територія: {area}",
"Area around {place}": "Околиці: {place}",
"Area around landmark": "Околиці орієнтира",
"Outline {place}": "Контур: {place}",
"Outline area": "Контур області",
"Find {place}": "Пошук: {place}",
"Find area": "Знайти область",
"Find recent imagery": "Знайти свіжі знімки",
"Find {what}": "Пошук: {what}",
"Find places": "Знайти місця",
"Drawing outline": "Малювання контуру",
"Searching imagery catalog": "Пошук у каталозі знімків",
"Loading imagery": "Завантаження знімків",
"Searching OpenStreetMap": "Пошук в OpenStreetMap",
"voice area|Area": "Область",
"buffer sized to the place": "буферна зона за розміром місця",
"enclosing mapped area": "охопна область на карті",
"the map was cleared while looking it up": "карту було очищено під час пошуку",
"OpenStreetMap coverage varies by place": "Покриття OpenStreetMap відрізняється залежно від місця",
"the catalog covers the last 30 days": "каталог охоплює останні 30 днів",
"there are more": "є ще",
"{radius} m around the landmark": "{radius} м навколо об’єкта",
"{count} parts": {
"few": "Частин: {count}",
"many": "Частин: {count}",
"one": "Частин: {count}",
"other": "Частин: {count}"
},
"{drawn} of {total} parts drawn": {
"few": "Намальовано частин: {drawn} з {total}",
"many": "Намальовано частин: {drawn} з {total}",
"one": "Намальовано частин: {drawn} з {total}",
"other": "Намальовано частин: {drawn} з {total}"
},
"{count} duplicate mappings merged": {
"few": "Об’єднано дублікатів: {count}",
"many": "Об’єднано дублікатів: {count}",
"one": "Об’єднано дублікатів: {count}",
"other": "Об’єднано дублікатів: {count}"
},
"listed the first {limit}": "показано перші {limit}",
"counted over the bounding box of {area}, not its outline": "підраховано за обмежувальним прямокутником ({area}), а не за контуром",
"Search cancelled": "Пошук скасовано",
"Sea Marks": "Морські знаки",
"Ski Pistes": "Гірськолижні траси"
}
+57
View File
@@ -2873,6 +2873,63 @@
"UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置",
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件",
"Voice place search (needs an Overpass server)": "语音地点搜索(需要 Overpass 服务器)",
"OSM Places": "OSM 地点",
"state or province": "州或省",
"county or district": "县或区",
"neighbourhood": "街区",
"natural region": "自然区域",
"Which {place}?": "是哪个 {place}?",
"Which place?": "是哪个地点?",
"Not drawn: {parts}": "未绘制:{parts}",
"approximate": "近似",
"{pct}% cloud": "云量 {pct}%",
"Over {area}": "{area} 上空",
"{count} of {total} acquisitions match": {
"other": "匹配影像:{count}/{total}"
},
"{n} acquisitions searched": {
"other": "已搜索影像:{n} 景"
},
"No matching imagery": "没有匹配的影像",
"Unknown number of {kind}": "{kind}:数量未知",
"About {count} {kind}": "{kind}:约 {count}",
"At least {count} {kind}": "{kind}:至少 {count}",
"{count} {kind}": "{kind}:{count}",
"In {area}": "位于 {area}",
"Area around {place}": "{place} 周边区域",
"Area around landmark": "地标周边区域",
"Outline {place}": "勾勒 {place} 轮廓",
"Outline area": "勾勒区域轮廓",
"Find {place}": "查找 {place}",
"Find area": "查找区域",
"Find recent imagery": "查找近期影像",
"Find {what}": "查找 {what}",
"Find places": "查找地点",
"Drawing outline": "正在勾勒轮廓",
"Searching imagery catalog": "正在搜索影像目录",
"Loading imagery": "正在加载影像",
"Searching OpenStreetMap": "正在搜索 OpenStreetMap",
"voice area|Area": "区域",
"buffer sized to the place": "按地点大小设置的缓冲区",
"enclosing mapped area": "包含该地点的地图区域",
"the map was cleared while looking it up": "查找期间地图已清空",
"OpenStreetMap coverage varies by place": "OpenStreetMap 的覆盖程度因地而异",
"the catalog covers the last 30 days": "目录涵盖最近 30 天",
"there are more": "还有更多",
"{radius} m around the landmark": "地标周围 {radius} 米",
"{count} parts": {
"other": "部分数:{count}"
},
"{drawn} of {total} parts drawn": {
"other": "已绘制部分:{drawn}/{total}"
},
"{count} duplicate mappings merged": {
"other": "已合并重复项:{count}"
},
"listed the first {limit}": "已列出前 {limit} 个",
"counted over the bounding box of {area}, not its outline": "按外接矩形({area})计数,而非其轮廓",
"Search cancelled": "搜索已取消",
"Sea Marks": "航标",
"Ski Pistes": "滑雪道"
}
+57
View File
@@ -2873,6 +2873,63 @@
"UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
"Voice place search (needs an Overpass server)": "語音地點搜索(需要 Overpass 服務器)",
"OSM Places": "OSM 地點",
"state or province": "州或省",
"county or district": "縣或區",
"neighbourhood": "街區",
"natural region": "自然區域",
"Which {place}?": "是哪個 {place}?",
"Which place?": "是哪個地點?",
"Not drawn: {parts}": "未繪製:{parts}",
"approximate": "近似",
"{pct}% cloud": "雲量 {pct}%",
"Over {area}": "{area} 上空",
"{count} of {total} acquisitions match": {
"other": "匹配影像:{count}/{total}"
},
"{n} acquisitions searched": {
"other": "已搜索影像:{n} 景"
},
"No matching imagery": "沒有匹配的影像",
"Unknown number of {kind}": "{kind}:數量未知",
"About {count} {kind}": "{kind}:約 {count}",
"At least {count} {kind}": "{kind}:至少 {count}",
"{count} {kind}": "{kind}:{count}",
"In {area}": "位於 {area}",
"Area around {place}": "{place} 周邊區域",
"Area around landmark": "地標周邊區域",
"Outline {place}": "勾勒 {place} 輪廓",
"Outline area": "勾勒區域輪廓",
"Find {place}": "查找 {place}",
"Find area": "查找區域",
"Find recent imagery": "查找近期影像",
"Find {what}": "查找 {what}",
"Find places": "查找地點",
"Drawing outline": "正在勾勒輪廓",
"Searching imagery catalog": "正在搜索影像目錄",
"Loading imagery": "正在加載影像",
"Searching OpenStreetMap": "正在搜索 OpenStreetMap",
"voice area|Area": "區域",
"buffer sized to the place": "按地點大小設置的緩衝區",
"enclosing mapped area": "包含該地點的地圖區域",
"the map was cleared while looking it up": "查找期間地圖已清空",
"OpenStreetMap coverage varies by place": "OpenStreetMap 的覆蓋程度因地而異",
"the catalog covers the last 30 days": "目錄涵蓋最近 30 天",
"there are more": "還有更多",
"{radius} m around the landmark": "地標周圍 {radius} 米",
"{count} parts": {
"other": "部分數:{count}"
},
"{drawn} of {total} parts drawn": {
"other": "已繪製部分:{drawn}/{total}"
},
"{count} duplicate mappings merged": {
"other": "已合併重複項:{count}"
},
"listed the first {limit}": "已列出前 {limit} 個",
"counted over the bounding box of {area}, not its outline": "按外接矩形({area})計數,而非其輪廓",
"Search cancelled": "搜索已取消",
"Sea Marks": "航標",
"Ski Pistes": "滑雪道"
}
+1
View File
@@ -663,6 +663,7 @@
"satellite",
"standard",
"view band|METRO",
"voice area|Area",
"voice|OFF",
"{count} MSGS",
"{count} feeds live",
+184
View File
@@ -0,0 +1,184 @@
import * as Cesium from 'cesium';
import { msg } from '../../i18n/marker.js';
import { OSM_PLACES_LAYER_ID } from './model.js';
import { createNominatimStatus } from '../../sources/nominatimStatus.js';
export * from './model.js';
const OVERLAY_SOURCE_ID = OSM_PLACES_LAYER_ID;
/** Named results that get a map label; the rest are points. */
const LABEL_LIMIT = 48;
const COLOR = '#7fe3ff';
/**
* OSM Places: the results of the last voice OpenStreetMap search ("hospitals
* in Kathmandu"), drawn as points with name labels and queryable by
* analyst_query. It fetches nothing itself; `setResults` replaces the set.
* The search runs only on an operator-configured Overpass, so without one
* the layer stays empty. Session-only: never in share links or stored layer
* state.
* @param {{overlayHost: object, overpassConfigured?: () => Promise<boolean|null>}} options
*/
export function createOsmPlacesLayer({
overlayHost,
// The same once-per-page capability probe as Nominatim's, on Overpass.
overpassConfigured = createNominatimStatus({ url: '/api/overpass/status' }),
} = {}) {
if (!overlayHost) throw new TypeError('OSM Places requires an overlay host');
let viewer = null;
let dataSource = null;
let enabled = false;
let records = [];
let meta = null;
let lastUpdate = null;
/** true / false from the server's probe, null while unknown. */
let configured = null;
function render() {
if (!dataSource) return;
dataSource.entities.removeAll();
const color = Cesium.Color.fromCssColorString(COLOR);
const overlay = [];
for (const record of records) {
const position = Cesium.Cartesian3.fromDegrees(record.lon, record.lat);
dataSource.entities.add({
id: `osm-place:${record.id}`,
position,
point: {
pixelSize: 9,
color,
outlineColor: Cesium.Color.BLACK.withAlpha(0.8),
outlineWidth: 2,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
// On top only up close (as bikeshare stations): beyond that the
// depth test keeps far-side points from showing through the globe.
disableDepthTestDistance: 2500,
},
});
if (record.name && overlay.length < LABEL_LIMIT)
overlay.push({
id: record.id,
position,
variant: 'label',
title: record.name.slice(0, 40),
accent: COLOR,
priority: LABEL_LIMIT - overlay.length,
collisionGroup: 'ambient-label',
paintLane: 'ambient-label',
interactive: false,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
gapPx: 12,
verticalOnly: true,
placement: 'above',
});
}
if (enabled)
overlayHost.setEntries(OVERLAY_SOURCE_ID, overlay, {
cohortLimit: LABEL_LIMIT,
collisionCapacity: LABEL_LIMIT,
moving: false,
});
}
return {
id: OSM_PLACES_LAYER_ID,
name: 'OSM Places',
icon: '⌖',
source: 'OpenStreetMap',
updateInterval: 0,
/** Unavailable only once the server says no Overpass is configured. */
getSourceAvailability() {
return configured === false
? {
available: false,
source: OSM_PLACES_LAYER_ID,
reason: 'Needs an operator-configured Overpass server',
}
: { available: true, source: OSM_PLACES_LAYER_ID, reason: null };
},
init(nextViewer) {
viewer = nextViewer;
Promise.resolve(overpassConfigured?.())
.then((answer) => {
if (typeof answer === 'boolean') configured = answer;
})
.catch(() => {});
dataSource = new Cesium.CustomDataSource(OSM_PLACES_LAYER_ID);
dataSource.show = false;
viewer.dataSources.add(dataSource);
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
},
enable() {
enabled = true;
if (dataSource) dataSource.show = true;
overlayHost.setVisible(OVERLAY_SOURCE_ID, true);
render();
return true;
},
disable() {
enabled = false;
if (dataSource) dataSource.show = false;
overlayHost.clearSource(OVERLAY_SOURCE_ID);
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
return true;
},
/** Nothing to poll: results change only through setResults. */
async update() {
return true;
},
destroy() {
enabled = false;
overlayHost.clearSource(OVERLAY_SOURCE_ID);
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
if (dataSource && viewer) viewer.dataSources.remove(dataSource, true);
dataSource = null;
viewer = null;
records = [];
meta = null;
lastUpdate = null;
},
/**
* Replace the result set.
* @param {object[]} next Records from `mapOsmFeature`.
* @param {{kind: string, label: string, area: string}} nextMeta
*/
setResults(next, nextMeta) {
records = Array.isArray(next) ? next.slice() : [];
meta = nextMeta ? { ...nextMeta } : null;
lastUpdate = Date.now();
render();
},
/** The current search, for panels and voice. */
getResultsMeta() {
return meta ? { ...meta, count: records.length } : null;
},
getAnalystRecords(maxCount = 2000) {
if (!enabled) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
return records.slice(0, limit).map((record) => ({ ...record }));
},
getStats() {
return {
count: records.length,
lastUpdate,
error: null,
statusMessage: meta
? `${meta.label} · ${meta.area}`
: msg('Voice place search (needs an Overpass server)'),
};
},
};
}
+152
View File
@@ -0,0 +1,152 @@
// OSM Places holds the last voice search's results: queryable while shown,
// labelled through the overlay host, and nothing when switched off.
//
// Run with: npm test
import test from 'node:test';
import assert from 'node:assert/strict';
import { createOsmPlacesLayer } from './index.js';
function fakeOverlayHost() {
const calls = [];
return {
calls,
setEntries: (...args) => calls.push(['setEntries', ...args]),
clearSource: (...args) => calls.push(['clearSource', ...args]),
setVisible: (...args) => calls.push(['setVisible', ...args]),
};
}
const RECORDS = [
{
id: 'node/1',
lat: 27.7,
lon: 85.3,
name: 'Bir Hospital',
kind: 'hospital',
},
{ id: 'node/2', lat: 27.71, lon: 85.31, name: null, kind: 'hospital' },
];
test('results are queryable only while the layer is on, and labels follow names', () => {
const host = fakeOverlayHost();
const layer = createOsmPlacesLayer({ overlayHost: host });
layer.setResults(RECORDS, {
kind: 'hospital',
label: 'hospitals',
area: 'Kathmandu',
});
assert.deepEqual(layer.getAnalystRecords(), [], 'off: nothing to count');
assert.equal(layer.getStats().count, 2);
assert.equal(layer.getStats().statusMessage, 'hospitals · Kathmandu');
layer.enable();
const records = layer.getAnalystRecords();
assert.equal(records.length, 2);
records[0].name = 'changed';
assert.equal(
layer.getAnalystRecords()[0].name,
'Bir Hospital',
'callers get copies',
);
assert.deepEqual(layer.getResultsMeta(), {
kind: 'hospital',
label: 'hospitals',
area: 'Kathmandu',
count: 2,
});
layer.disable();
assert.ok(
host.calls.some(
([name, id]) => name === 'clearSource' && id === 'osm-places',
),
);
});
test('an empty layer tells the operator how to fill it', () => {
const layer = createOsmPlacesLayer({ overlayHost: fakeOverlayHost() });
assert.match(layer.getStats().statusMessage, /needs an Overpass server/);
assert.throws(() => createOsmPlacesLayer(), TypeError);
});
test('destroy and re-init expose a fresh empty session', () => {
const host = fakeOverlayHost();
const layer = createOsmPlacesLayer({ overlayHost: host });
layer.setResults(RECORDS, {
kind: 'hospital',
label: 'hospitals',
area: 'Kathmandu',
});
assert.ok(layer.getStats().lastUpdate);
layer.destroy();
const dataSources = {
added: [],
removed: [],
add(source) {
this.added.push(source);
return source;
},
remove(source, destroy) {
this.removed.push([source, destroy]);
return true;
},
};
layer.init({ dataSources });
layer.enable();
assert.equal(layer.getResultsMeta(), null);
assert.deepEqual(layer.getAnalystRecords(), []);
assert.deepEqual(layer.getStats(), {
count: 0,
lastUpdate: null,
error: null,
statusMessage: 'Voice place search (needs an Overpass server)',
});
});
test('without an operator Overpass the layer shows as unavailable', async () => {
const added = [];
const viewer = { dataSources: { add: (ds) => added.push(ds), remove() {} } };
const off = createOsmPlacesLayer({
overlayHost: fakeOverlayHost(),
overpassConfigured: async () => false,
});
assert.equal(
off.getSourceAvailability().available,
true,
'unknown until asked',
);
off.init(viewer);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(off.getSourceAvailability().available, false);
const unknown = createOsmPlacesLayer({
overlayHost: fakeOverlayHost(),
overpassConfigured: async () => null,
});
unknown.init(viewer);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(
unknown.getSourceAvailability().available,
true,
'an unknown answer keeps it listed',
);
});
test('places are depth-tested past close range, so they never show through the globe', () => {
const added = [];
const viewer = { dataSources: { add: (ds) => added.push(ds), remove() {} } };
const layer = createOsmPlacesLayer({
overlayHost: fakeOverlayHost(),
overpassConfigured: async () => true,
});
layer.init(viewer);
layer.enable();
layer.setResults(RECORDS, {
kind: 'hospital',
label: 'hospitals',
area: 'Kathmandu',
});
const point = added[0].entities.values[0].point;
const distance = point.disableDepthTestDistance.getValue();
assert.ok(
Number.isFinite(distance) && distance <= 5000,
`depth test off only within ${distance} m`,
);
});
+111
View File
@@ -0,0 +1,111 @@
/**
* OSM Places records: OpenStreetMap features found by a voice search,
* mapped to plain analyst records. Pure — no Cesium.
* @module layers/osmPlaces/model
*/
export const OSM_PLACES_LAYER_ID = 'osm-places';
/** A node and a way/relation of the same name this close are one site mapped twice. */
const SAME_SITE_M = 75;
/** Longest tag value kept; OSM names are open text that reaches the model. */
const MAX_TEXT = 120;
/**
* A tag value as plain bounded text: control characters (newlines, tabs)
* become spaces; format characters (bidi overrides, zero-width spaces) are
* dropped, except the zero-width (non-)joiners Persian and Indic names spell
* with; runs of whitespace collapse, and long values are cut with an
* ellipsis.
*/
function text(value) {
if (typeof value !== 'string') return null;
const clean = value
.replace(/\p{Cc}+/gu, ' ')
.replace(/(?![\u200c\u200d])\p{Cf}/gu, '')
.replace(/\s+/g, ' ')
.trim();
if (!clean) return null;
// Cut on code points so an emoji's surrogate pair is never split.
const chars = Array.from(clean);
return chars.length <= MAX_TEXT
? clean
: `${chars
.slice(0, MAX_TEXT - 1)
.join('')
.trimEnd()}…`;
}
function distanceM(a, b) {
const toRad = Math.PI / 180;
const x = (b.lon - a.lon) * toRad * Math.cos(((a.lat + b.lat) / 2) * toRad);
const y = (b.lat - a.lat) * toRad;
return Math.hypot(x, y) * 6_371_000;
}
/**
* One feature from `/api/osm/features` → an analyst record.
* @param {{id: string, lat: number, lon: number, tags?: object}} feature
* @param {{id: string, label: string}} preset
* @returns {object|null}
*/
export function mapOsmFeature(feature, preset) {
if (!Number.isFinite(feature?.lat) || !Number.isFinite(feature?.lon))
return null;
const tags = feature.tags || {};
const [osmType] = String(feature.id || '').split('/');
return {
id: String(feature.id),
lat: feature.lat,
lon: feature.lon,
name: text(tags['name:en']) || text(tags.name),
kind: preset.id,
operator: text(tags.operator) || text(tags.brand),
emergency: text(tags.emergency),
wheelchair: text(tags.wheelchair),
openingHours: text(tags.opening_hours),
city: text(tags['addr:city']),
osmType: ['node', 'way', 'relation'].includes(osmType) ? osmType : null,
};
}
/**
* Remove double counting without merging distinct places:
*
* - the same OSM element (identical `type/id`) is counted once;
* - a node and a way/relation with the same name within 75 m are one site
* mapped twice (a point on the building and the building outline), and
* merge;
* - two nodes, or two ways, are always distinct places — two neighbouring
* cafés of one chain are two cafés — and never merge, whatever their names.
*
* @param {object[]} records
* @returns {{records: object[], merged: number}}
*/
export function dedupeOsmRecords(records) {
const kept = [];
const seen = new Set();
let merged = 0;
for (const record of records) {
if (seen.has(record.id)) {
merged += 1;
continue;
}
seen.add(record.id);
const name = record.name?.toLowerCase();
const isNode = record.osmType === 'node';
const sameSite =
name &&
record.osmType &&
kept.some(
(other) =>
other.osmType &&
(other.osmType === 'node') !== isNode &&
other.name?.toLowerCase() === name &&
distanceM(other, record) <= SAME_SITE_M,
);
if (sameSite) merged += 1;
else kept.push(record);
}
return { records: kept, merged };
}
+10
View File
@@ -1209,6 +1209,16 @@ export function createRecentImageryLayer({
notify('alpha');
},
/** Stop catalog/preview work owned by a cancelled voice action. */
cancelPendingSearch() {
// Without a note the emptied strip would read as "no imagery".
if (_searching) _notice = msg('Search cancelled');
cancelSearch();
clearPending();
syncRender();
notify();
},
/**
* Forget the box, the candidates, both pins, the preview and any pending
* pick; reset opacity and split and cancel the box tool. Mode and
+10
View File
@@ -1385,3 +1385,13 @@ test('with SELECT BOX armed, Escape clears the preview first and the next Escape
assert.equal(f.snap().toolActive, false);
await tool.destroy();
});
test('a voice search cancelled mid-flight says so instead of looking empty', async () => {
const f = fixture();
f.layer.enable();
f.layer.setBox(BOX);
assert.equal(f.snap().searching, true);
f.layer.cancelPendingSearch();
assert.equal(f.snap().searching, false);
assert.equal(f.snap().notice, 'Search cancelled');
});
+179 -39
View File
@@ -19,23 +19,52 @@ const DATA = { status: 200, body: '{"elements":[]}' };
test('disk cache rejects old refusals for fresh and stale reads but preserves last-good data', async () => {
const key = `overpass-cache-regression-${randomUUID()}`;
const directory = path.join(process.cwd(), '.gev-cache', 'overpass');
const file = path.join(directory, `${createHash('sha1').update(key).digest('hex')}.json`);
const file = path.join(
directory,
`${createHash('sha1').update(key).digest('hex')}.json`,
);
await mkdir(directory, { recursive: true });
try {
for (const refusal of [
{ status: 406 }, { status: 429 }, { status: 503 },
{ status: 200, rateLimited: true }, { status: 200, runtimeError: true },
{ status: 406 },
{ status: 429 },
{ status: 503 },
{ status: 200, rateLimited: true },
{ status: 200, runtimeError: true },
]) {
await writeFile(file, JSON.stringify({ ...DATA, cachedAt: Date.now(), ...refusal }));
assert.equal(await readOverpassDisk(key, 60000), null, `fresh ${JSON.stringify(refusal)}`);
assert.equal(await readOverpassDisk(key, Infinity), null, `stale ${JSON.stringify(refusal)}`);
await writeFile(
file,
JSON.stringify({ ...DATA, cachedAt: Date.now(), ...refusal }),
);
assert.equal(
await readOverpassDisk(key, 60000),
null,
`fresh ${JSON.stringify(refusal)}`,
);
assert.equal(
await readOverpassDisk(key, Infinity),
null,
`stale ${JSON.stringify(refusal)}`,
);
}
const good = { ...DATA, cachedAt: Date.now() - 120000 };
await writeFile(file, JSON.stringify(good));
assert.equal(await readOverpassDisk(key, 60000), null, 'expired good data misses normal TTL');
assert.deepEqual(await readOverpassDisk(key, Infinity), good, 'last-good data survives an outage');
assert.equal(
await readOverpassDisk(key, 60000),
null,
'expired good data misses normal TTL',
);
assert.deepEqual(
await readOverpassDisk(key, Infinity),
good,
'last-good data survives an outage',
);
await writeFile(file, '{invalid');
assert.equal(await readOverpassDisk(key, Infinity), null, 'corrupt cache is ignored');
assert.equal(
await readOverpassDisk(key, Infinity),
null,
'corrupt cache is ignored',
);
} finally {
await unlink(file);
}
@@ -49,27 +78,45 @@ test('only a 2xx that is neither rate-limited nor a runtime error is data', () =
// The measured refusal, and its neighbours. `< 500` admitted every one.
for (const status of [400, 403, 406, 410, 429]) {
assert.equal(overpassPayloadIsData({ status }), false, `${status} is not data`);
assert.equal(
overpassPayloadIsData({ status }),
false,
`${status} is not data`,
);
}
assert.equal(overpassPayloadIsData({ status: 502 }), false);
// A 200 can still not be data: Overpass reports runtime failures in the body.
assert.equal(overpassPayloadIsData({ status: 200, runtimeError: true }), false);
assert.equal(overpassPayloadIsData({ status: 200, rateLimited: true }), false);
assert.equal(
overpassPayloadIsData({ status: 200, runtimeError: true }),
false,
);
assert.equal(
overpassPayloadIsData({ status: 200, rateLimited: true }),
false,
);
assert.equal(overpassPayloadIsData({}), false);
assert.equal(overpassPayloadIsData(null), false);
});
// Configured endpoints replace defaults; refusals are sanitized and cooled down.
test('configured failover accepts empty data and keeps the application identity', async () => {
const endpoints = ['https://first.example/query?secret=one', 'http://localhost:12345/api'];
const endpoints = [
'https://first.example/query?secret=one',
'http://localhost:12345/api',
];
const seen = [];
const payload = await fetchOverpassPayload('data=x', 1024, {
endpoints,
fetchImpl: async (url, options) => {
seen.push(url);
assert.equal(options.headers['User-Agent'], 'gods-eye-view/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)');
assert.equal(
options.headers['User-Agent'],
'gods-eye-view/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)',
);
assert.equal(options.redirect, 'error');
return new Response(seen.length === 1 ? url : DATA.body, { status: seen.length === 1 ? 406 : 200 });
return new Response(seen.length === 1 ? url : DATA.body, {
status: seen.length === 1 ? 406 : 200,
});
},
});
assert.deepEqual(seen, endpoints);
@@ -80,11 +127,19 @@ test('configured failover accepts empty data and keeps the application identity'
test('406 and 429 honor Retry-After across different queries without more egress', async () => {
for (const status of [406, 429]) {
const endpoint = `https://cooldown-${status}.example/private?token=secret`;
let count = 0, now = 1_000_000;
const options = { endpoints: [endpoint], now: () => now, fetchImpl: async () => {
count++;
return new Response(`Refused ${endpoint}`, { status, headers: { 'Retry-After': '90' } });
} };
let count = 0,
now = 1_000_000;
const options = {
endpoints: [endpoint],
now: () => now,
fetchImpl: async () => {
count++;
return new Response(`Refused ${endpoint}`, {
status,
headers: { 'Retry-After': '90' },
});
},
};
const first = await fetchOverpassPayload('first', 1024, options);
assert.equal(first.status, status);
assert.equal(first.retryAfterMs, 90_000);
@@ -102,11 +157,23 @@ test('406 and 429 honor Retry-After across different queries without more egress
test('HTTP-date Retry-After is respected, and absent delays use bounded backoff', async () => {
let now = Date.parse('2026-09-23T12:00:00Z');
const endpoint = 'https://dated.example/api';
const payload = await fetchOverpassPayload('x', 1024, { endpoints: [endpoint], now: () => now,
fetchImpl: async () => new Response('busy', { status: 429, headers: { 'Retry-After': 'Wed, 23 Sep 2026 12:02:00 GMT' } }) });
const payload = await fetchOverpassPayload('x', 1024, {
endpoints: [endpoint],
now: () => now,
fetchImpl: async () =>
new Response('busy', {
status: 429,
headers: { 'Retry-After': 'Wed, 23 Sep 2026 12:02:00 GMT' },
}),
});
assert.equal(payload.retryAfterMs, 120_000);
const options = { endpoints: ['https://bounded.example'], now: () => now,
fetchImpl: async () => { throw new Error('https://bounded.example/secret'); } };
const options = {
endpoints: ['https://bounded.example'],
now: () => now,
fetchImpl: async () => {
throw new Error('https://bounded.example/secret');
},
};
for (let i = 0; i < 8; i++) {
const reply = await fetchOverpassPayload('x', 1024, options);
assert.ok(reply.retryAfterMs <= 300_000);
@@ -116,13 +183,27 @@ test('HTTP-date Retry-After is respected, and absent delays use bounded backoff'
});
test('production reader rejects oversized, malformed and runtime-failure bodies', async () => {
for (const [i, body] of ['x'.repeat(200), '{"remark":"runtime error: timed out","elements":[]}', 'rate_limited', '{}', 'not json'].entries()) {
const endpoints = [`https://bad-${i}.example/`, `https://good-${i}.example/`];
for (const [i, body] of [
'x'.repeat(200),
'{"remark":"runtime error: timed out","elements":[]}',
'rate_limited',
'{}',
'not json',
].entries()) {
const endpoints = [
`https://bad-${i}.example/`,
`https://good-${i}.example/`,
];
const seen = [];
const payload = await fetchOverpassPayload('data=x', 100, { endpoints, fetchImpl: async (url) => {
seen.push(url); return new Response(seen.length === 1 ? body : DATA.body);
} });
assert.equal(payload.body, DATA.body); assert.deepEqual(seen, endpoints);
const payload = await fetchOverpassPayload('data=x', 100, {
endpoints,
fetchImpl: async (url) => {
seen.push(url);
return new Response(seen.length === 1 ? body : DATA.body);
},
});
assert.equal(payload.body, DATA.body);
assert.deepEqual(seen, endpoints);
}
});
@@ -152,20 +233,69 @@ test('element data that mentions a failure phrase is data, and sets no cooldown'
assert.equal(error.retryAfterMs, 120_000);
});
test('caller cancellation aborts transport without cooling down the upstream', async () => {
const endpoint = `https://cancelled-${randomUUID()}.example/api`;
const controller = new AbortController();
const entered = Promise.withResolvers();
const first = fetchOverpassPayload('data=x', 1024, {
endpoints: [endpoint],
signal: controller.signal,
fetchImpl: async (_url, { signal }) => {
entered.resolve();
return new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
});
});
},
});
await entered.promise;
controller.abort();
await assert.rejects(first);
let retried = 0;
const next = await fetchOverpassPayload('data=y', 1024, {
endpoints: [endpoint],
fetchImpl: async () => {
retried += 1;
return new Response(DATA.body, { status: 200 });
},
});
assert.equal(
retried,
1,
'the next live caller is not held behind a cooldown',
);
assert.equal(next.body, DATA.body);
});
function proxyHandler() {
const plugin = createViteConfig({ mode: 'test' }).plugins.find(p => p.name === 'overpass-proxy');
const plugin = createViteConfig({ mode: 'test' }).plugins.find(
(p) => p.name === 'overpass-proxy',
);
const routes = new Map();
plugin.configureServer({ middlewares: { use: (route, handler) => routes.set(route, handler) } });
plugin.configureServer({
middlewares: { use: (route, handler) => routes.set(route, handler) },
});
return routes.get('/api/overpass');
}
function invoke(handler, body) {
const req = Readable.from([Buffer.from(body)]);
Object.assign(req, { method: 'POST', headers: {}, socket: { remoteAddress: '127.0.0.1' } });
Object.assign(req, {
method: 'POST',
headers: {},
socket: { remoteAddress: '127.0.0.1' },
});
return new Promise((resolve, reject) => {
const res = {
writeHead(status, headers) { this.status = status; this.headers = headers; },
end(body) { resolve({ status: this.status, headers: this.headers, body }); },
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end(body) {
resolve({ status: this.status, headers: this.headers, body });
},
};
Promise.resolve(handler(req, res)).catch(reject);
});
@@ -174,13 +304,19 @@ function invoke(handler, body) {
test('coalesced outage callers both receive last-good data, never a cached refusal', async (t) => {
const handler = proxyHandler();
const prior = process.env.OVERPASS_UPSTREAMS;
t.after(() => { if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS; else process.env.OVERPASS_UPSTREAMS = prior; });
t.after(() => {
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
else process.env.OVERPASS_UPSTREAMS = prior;
});
for (const status of [406, 503, 429]) {
process.env.OVERPASS_UPSTREAMS = `https://outage-${status}.example/api`;
const query = `[out:json][timeout:12];node(around:10,30.27,-97.74)["name"="${randomUUID()}"];out;`;
const body = `data=${encodeURIComponent(query)}`;
const directory = path.join(process.cwd(), '.gev-cache', 'overpass');
const file = path.join(directory, `${createHash('sha1').update(body).digest('hex')}.json`);
const file = path.join(
directory,
`${createHash('sha1').update(body).digest('hex')}.json`,
);
await mkdir(directory, { recursive: true });
// Last-good data was fetched from the instance configured above, so it
// carries that instance's tag the way the transport now writes it.
@@ -205,10 +341,14 @@ test('coalesced outage callers both receive last-good data, never a cached refus
const second = invoke(handler, body);
// The second request consumes its in-memory stream and joins the pending
// promise before releasing upstream. No network or elapsed-time sleep.
await new Promise(resolve => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
release.resolve();
for (const response of await Promise.all([first, second])) {
assert.equal(response.status, 200, `${status}: both callers use last-good data`);
assert.equal(
response.status,
200,
`${status}: both callers use last-good data`,
);
assert.equal(response.body, DATA.body);
assert.equal(response.headers['X-Overpass-Cache'], 'STALE');
}
+10 -3
View File
@@ -25,11 +25,14 @@ const css = readStylesheet(new URL('../style.css', import.meta.url));
function realtimeTools() { return GEV_REALTIME_TOOLS; }
test('Realtime schema exposes the authoritative 31-tool inventory', () => {
test('Realtime schema exposes the authoritative 34-tool inventory', () => {
const tools = realtimeTools();
assert.equal(tools.length, 31);
assert.equal(tools.length, 34);
const names = tools.map((tool) => tool.name);
assert.equal(new Set(names).size, 31, 'tool names are unique');
assert.equal(new Set(names).size, 34, 'tool names are unique');
assert.ok(names.includes('resolve_area'));
assert.ok(names.includes('find_imagery'));
assert.ok(names.includes('osm_query'));
assert.ok(names.includes('set_context_mode'));
assert.ok(names.includes('control_cockpit'));
assert.ok(names.includes('set_voice_inactivity_timeout'));
@@ -199,6 +202,10 @@ test('no unchanged Realtime tool definition drifts silently', () => {
'track_entity',
// Point-and-ask: 'pointer' sentinels and referent arguments.
'annotate_map',
// Voice geometry: new tools.
'resolve_area',
'find_imagery',
'osm_query',
]);
const unchanged = realtimeTools()
.filter((tool) => !TOUCHED.has(tool.name))
+16 -1
View File
@@ -54,9 +54,24 @@ function probeTransport(status) {
return { seen, fetchImpl };
}
const stalled = (init) => new Promise((_, reject) => init.signal.addEventListener('abort', () => reject(init.signal.reason)));
const stalledWithReferencedWait = (init) =>
new Promise((_, reject) => {
// AbortSignal.timeout() is intentionally unref'd in Node. Keep this one
// timeout test alive until the signal fires so an isolated test process
// cannot exit with a still-pending assertion.
const keepAlive = setTimeout(() => {}, 1000);
init.signal.addEventListener(
'abort',
() => {
clearTimeout(keepAlive);
reject(init.signal.reason);
},
{ once: true },
);
});
test('a stalled capability probe times out and queries proceed; no re-probe during backoff', async () => {
const { seen, fetchImpl } = probeTransport(stalled);
const { seen, fetchImpl } = probeTransport(stalledWithReferencedWait);
const services = createApplicationRequestServices({ fetchImpl, boundaryProbe: { timeoutMs: 20, retryMs: 60_000 } });
const started = Date.now();
assert.deepEqual(await services.boundaries.query('fixture'), []);
+2
View File
@@ -159,6 +159,8 @@ export function createNominatimOutlineClient({
const outline = {
polygons,
name: body.outline.name || null,
adminArea: body.outline.adminArea || null,
adminLevel: body.outline.adminLevel || null,
class: body.outline.class || null,
type: body.outline.type || null,
osm: body.outline.osm || null,
+12 -3
View File
@@ -44,20 +44,29 @@ test('an accepted outline comes back with its polygons and is remembered', async
reply(200, {
status: 'OK',
outline: {
name: 'Paris',
name: 'Bagmati Province',
adminArea: 'Bagmati Province',
adminLevel: 'admin1',
class: 'boundary',
type: 'administrative',
polygons: [[ring, hole]],
},
}),
);
const ask = { query: 'Paris', kind: 'city', lat: 48.85, lon: 2.35 };
const ask = {
query: 'Bagmati Province',
kind: 'admin',
lat: 27.72,
lon: 85.32,
};
const outline = await lookup(ask);
assert.deepEqual(outline.polygons, [[ring, hole]]);
assert.equal(outline.class, 'boundary');
assert.equal(outline.adminArea, 'Bagmati Province');
assert.equal(outline.adminLevel, 'admin1');
assert.match(
calls[0],
/^\/api\/geocode\/outline\?q=Paris&kind=city&lat=48\.850&lon=2\.350$/,
/^\/api\/geocode\/outline\?q=Bagmati\+Province&kind=admin&lat=27\.720&lon=85\.320$/,
);
await lookup(ask);
assert.equal(calls.length, 1, 'one request per ask in a session');
+62
View File
@@ -0,0 +1,62 @@
/**
* Browser client for `/api/osm/features`: one curated preset inside a box,
* as a count or as features. The server writes and bounds the Overpass
* query and answers only when an operator configured Overpass; this only
* names the preset, box, mode and limit.
* @module sources/osmFeatures
*/
/**
* @param {{fetchImpl?: typeof fetch, endpoint?: string}} [options]
* @returns {(request: {preset: string, bbox: number[], mode?: 'count'|'features', limit?: number}, options?: {signal?: AbortSignal}) => Promise<object>}
*/
export function createOsmFeatureSource({
fetchImpl = (...args) => globalThis.fetch(...args),
endpoint = '/api/osm/features',
} = {}) {
return async function searchOsmFeatures(
{ preset, bbox, mode = 'features', limit },
{ signal } = {},
) {
const params = new URLSearchParams({
preset,
bbox: bbox.map((v) => Number(v).toFixed(5)).join(','),
mode,
});
if (Number.isFinite(limit)) params.set('limit', String(limit));
let response;
try {
response = await fetchImpl(`${endpoint}?${params}`, { signal });
} catch (error) {
if (signal?.aborted) throw error;
return {
ok: false,
status: 0,
error: 'OpenStreetMap search is unreachable.',
};
}
let body = null;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok)
return {
ok: false,
status: response.status,
code: body?.code || null,
error:
body?.error || `OpenStreetMap search failed (${response.status}).`,
};
// A garbled success (a proxy's error page) is a failure, not zero places.
if (!body || typeof body !== 'object')
return {
ok: false,
status: response.status,
code: null,
error: 'OpenStreetMap search returned an unreadable answer.',
};
return { ok: true, ...body };
};
}
+94
View File
@@ -0,0 +1,94 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createOsmFeatureSource } from './osmFeatures.js';
const respond = (status, body) => ({
ok: status >= 200 && status < 300,
status,
json: async () => {
if (typeof body === 'string') throw new SyntaxError('not JSON');
return body;
},
});
test('the request names only the preset, box, mode and limit', async () => {
let asked = null;
const search = createOsmFeatureSource({
fetchImpl: async (url) => {
asked = new URL(url, 'http://localhost');
return respond(200, { features: [], count: 0 });
},
});
const result = await search({
preset: 'hospital',
bbox: [85.2, 27.6, 85.4, 27.8],
limit: 50,
});
assert.equal(result.ok, true);
assert.equal(asked.pathname, '/api/osm/features');
assert.equal(asked.searchParams.get('preset'), 'hospital');
assert.equal(
asked.searchParams.get('bbox'),
'85.20000,27.60000,85.40000,27.80000',
);
assert.equal(asked.searchParams.get('mode'), 'features');
assert.equal(asked.searchParams.get('limit'), '50');
});
test('an aborted search rethrows; a network failure is a refusal', async () => {
const controller = new AbortController();
const aborting = createOsmFeatureSource({
fetchImpl: async () => {
controller.abort();
throw new DOMException('Aborted', 'AbortError');
},
});
await assert.rejects(
aborting(
{ preset: 'hospital', bbox: [0, 0, 1, 1] },
{ signal: controller.signal },
),
{ name: 'AbortError' },
);
const offline = createOsmFeatureSource({
fetchImpl: async () => {
throw new TypeError('fetch failed');
},
});
const refused = await offline({ preset: 'hospital', bbox: [0, 0, 1, 1] });
assert.deepEqual([refused.ok, refused.status], [false, 0]);
});
test('server refusals keep their status and code', async () => {
const notConfigured = createOsmFeatureSource({
fetchImpl: async () =>
respond(503, {
error: 'Place search needs a configured Overpass server.',
code: 'OVERPASS_NOT_CONFIGURED',
}),
});
const refused = await notConfigured({
preset: 'hospital',
bbox: [0, 0, 1, 1],
});
assert.equal(refused.ok, false);
assert.equal(refused.status, 503);
assert.equal(refused.code, 'OVERPASS_NOT_CONFIGURED');
const busy = createOsmFeatureSource({
fetchImpl: async () => respond(429, '<html>Too Many Requests</html>'),
});
const limited = await busy({ preset: 'hospital', bbox: [0, 0, 1, 1] });
assert.equal(limited.ok, false);
assert.equal(limited.status, 429);
assert.equal(limited.code, null);
assert.match(limited.error, /429/);
});
test('a success whose body is not JSON is a failure, not zero places', async () => {
const garbled = createOsmFeatureSource({
fetchImpl: async () => respond(200, '<html>proxy error</html>'),
});
const result = await garbled({ preset: 'hospital', bbox: [0, 0, 1, 1] });
assert.equal(result.ok, false);
assert.equal(result.status, 200);
});
+81
View File
@@ -0,0 +1,81 @@
// THE BOUNDED ANSWER CACHE — memory and disk bounds for the Nominatim area
// and OSM routes. Every memory insertion is bounded (disk hits included), and
// the disk keeps to its entry and byte quotas, removing expired files first.
//
// Run with: npm test
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { createBoundedCache } from '../../server/providers/common/boundedCache.js';
async function tempDir() {
return fsp.mkdtemp(path.join(os.tmpdir(), 'gev-bounded-cache-'));
}
test('disk hits are bounded in memory like fresh answers', async () => {
const dir = await tempDir();
const writer = createBoundedCache({
dir,
ttlMs: 60_000,
maxMemoryEntries: 100,
maxDiskEntries: 200,
});
for (let i = 0; i < 75; i += 1) await writer.set(`k${i}`, `v${i}`);
const reader = createBoundedCache({
dir,
ttlMs: 60_000,
maxMemoryEntries: 10,
maxDiskEntries: 200,
});
for (let i = 0; i < 75; i += 1)
assert.equal(await reader.get(`k${i}`), `v${i}`);
assert.equal(reader.memorySize, 10);
await fsp.rm(dir, { recursive: true, force: true });
});
test('the disk keeps to its entry and byte quotas and drops expired files', async () => {
const dir = await tempDir();
let clock = 1_000_000;
const cache = createBoundedCache({
dir,
ttlMs: 10_000,
maxDiskEntries: 5,
maxDiskBytes: 10_000,
now: () => clock,
});
for (let i = 0; i < 12; i += 1) await cache.set(`k${i}`, 'x'.repeat(100));
await cache.prune();
let files = await fsp.readdir(dir);
assert.ok(files.length <= 5, `${files.length} files`);
await cache.set('big', 'y'.repeat(20_000));
await cache.prune();
const sizes = await Promise.all(
(await fsp.readdir(dir)).map(
async (name) => (await fsp.stat(path.join(dir, name))).size,
),
);
assert.ok(sizes.reduce((a, b) => a + b, 0) <= 10_000, 'byte quota holds');
// Age every file past the TTL, then write once more: the old files go.
const past = new Date(Date.now() - 3_600_000);
for (const name of await fsp.readdir(dir))
await fsp.utimes(path.join(dir, name), past, past);
clock = Date.now();
await cache.set('fresh', 'z');
await cache.prune();
files = await fsp.readdir(dir);
assert.equal(files.length, 1);
assert.equal(await cache.get('k0'), undefined);
await fsp.rm(dir, { recursive: true, force: true });
});
test('an expired entry is not served', async () => {
let clock = 0;
const cache = createBoundedCache({ dir: null, ttlMs: 100, now: () => clock });
await cache.set('a', 1);
clock = 50;
assert.equal(await cache.get('a'), 1);
clock = 500;
assert.equal(await cache.get('a'), undefined);
});
+49 -4
View File
@@ -20,7 +20,11 @@ import {
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
import { keySetupEndpoint } from 'gods-eye-view/server/standalone/key-setup';
import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import {
GEV_REALTIME_TOOLS,
offeredTools,
realtimeTools,
} from '../../server/providers/openai/tools.js';
import { createDebugLogHandler } from '../../server/providers/openai/debug-log.js';
import { attachVoiceResult } from '../voice/speech.js';
import { sanitizeDebugValue } from '../voice/realtimeDiagnostics.js';
@@ -164,6 +168,7 @@ test('weather-only requests share upstream work and retain fresh and stale respo
test('Realtime handler preserves tools and default instructions, isolates supplied annotation guidance, and keeps the upstream key server-side', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
env(t, 'OVERPASS_UPSTREAMS', undefined);
const sent = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
assert.equal(url, 'https://api.openai.com/v1/realtime/client_secrets');
@@ -194,7 +199,7 @@ test('Realtime handler preserves tools and default instructions, isolates suppli
sent.at(-1).session.instructions,
realtimeInstructions(guidance),
);
assert.deepEqual(sent.at(-1).session.tools, GEV_REALTIME_TOOLS);
assert.deepEqual(sent.at(-1).session.tools, realtimeTools());
}
assert.notEqual(sent[0].session.instructions, sent[1].session.instructions);
assert.equal(sent[0].session.instructions, sent[2].session.instructions);
@@ -580,6 +585,36 @@ test('OAuth login endpoint selects an existing local sign-in without launching C
assert.equal(response.body.includes('fixture-existing-secret'), false);
});
test('osm_query is offered only when an operator configured Overpass', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
const sent = [];
t.mock.method(globalThis, 'fetch', async (_url, options) => {
sent.push(JSON.parse(options.body).session);
return Response.json({ value: 'fixture-ephemeral' });
});
const mint = () =>
request(install(openAiRealtimeProxy({})).get('/api/realtime/token'), {
url: '/',
});
env(t, 'OVERPASS_UPSTREAMS', undefined);
await mint();
env(t, 'OVERPASS_UPSTREAMS', 'https://overpass.example/api/interpreter');
await mint();
const [plain, configured] = sent;
const names = (session) => session.tools.map((tool) => tool.name);
assert.equal(names(plain).includes('osm_query'), false);
assert.doesNotMatch(plain.instructions, /osm_query/);
assert.ok(names(plain).includes('resolve_area'));
assert.ok(names(plain).includes('find_imagery'));
assert.deepEqual(configured.tools, GEV_REALTIME_TOOLS);
assert.match(configured.instructions, /osm_query/);
assert.equal(
configured.instructions,
realtimeInstructions(undefined, { overpass: true }),
);
});
test('caption transcription is on by default, reported to the client meter, and can be turned off', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
@@ -607,6 +642,7 @@ test('caption transcription is on by default, reported to the client meter, and
test('Realtime sessions carry supplied tools, and the standalone voice adds the catalog queries', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
env(t, 'OVERPASS_UPSTREAMS', undefined);
const sent = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
sent.push(JSON.parse(options.body));
@@ -619,7 +655,11 @@ test('Realtime sessions carry supplied tools, and the standalone voice adds the
),
);
assert.equal(response.status, 200);
assert.deepEqual(sent.at(-1).session.tools, tools);
// Without an operator Overpass, osm_query is left out of the session.
assert.deepEqual(sent.at(-1).session.tools, offeredTools(tools));
assert.ok(
!sent.at(-1).session.tools.some(({ name }) => name === 'osm_query'),
);
const names = tools.map((tool) => tool.name);
assert.equal(new Set(names).size, names.length);
assert.deepEqual(
@@ -707,13 +747,18 @@ test('Realtime service configuration selects compatible endpoint/model without f
endpoint: 'https://voice.example/client-secrets',
models: { standard: 'configured-model' },
resolveApiKey: () => 'server-fixture',
// Pinned, so the expected tool list never depends on this machine's env.
overpassConfigured: () => false,
fetchImpl: async (url, options) => {
assert.equal(url, 'https://voice.example/client-secrets');
assert.equal(options.redirect, 'error');
assert.equal(options.headers.Authorization, 'Bearer server-fixture');
const payload = JSON.parse(options.body);
assert.equal(payload.session.model, 'configured-model');
assert.deepEqual(payload.session.tools, GEV_REALTIME_TOOLS);
assert.deepEqual(
payload.session.tools,
realtimeTools({ overpass: false }),
);
return Response.json({ value: 'short-lived-fixture' });
},
},
+37 -9
View File
@@ -785,8 +785,8 @@ test('administrative boundaries must be at the asked level', () => {
assert.equal(picked.outline.osm, 'relation/9');
});
test('outline selection does not publish child-owned admin geometry hooks', () => {
for (const [name, addresstype] of [
test('admin identity comes only from unambiguous evidence on the selected row', () => {
for (const [name, addresstype, adminLevel] of [
['Nepal', 'country', 'country'],
['Bagmati Province', 'province', 'admin1'],
['Province of Bagmati', 'province', 'admin1'],
@@ -799,8 +799,8 @@ test('outline selection does not publish child-owned admin geometry hooks', () =
kind: 'admin',
query: name,
});
assert.equal('adminArea' in outline, false);
assert.equal('adminLevel' in outline, false);
assert.equal(outline.adminArea, name);
assert.equal(outline.adminLevel, adminLevel);
}
for (const row of [
@@ -842,25 +842,53 @@ test('outline selection does not publish child-owned admin geometry hooks', () =
[boundary({ name: 'Bagmati Zone', addresstype: 'state' })],
{ kind: 'admin', query: 'Bagmati Zone' },
).outline;
assert.equal('adminArea' in retired, false);
assert.equal(retired.adminArea, 'Bagmati Zone');
assert.equal('adminLevel' in retired, false);
const retiredPrefix = selectOutlineResult(
[boundary({ name: 'Zone of Bagmati', addresstype: 'state' })],
{ kind: 'admin', query: 'Zone of Bagmati' },
).outline;
assert.equal('adminArea' in retiredPrefix, false);
assert.equal(retiredPrefix.adminArea, 'Zone of Bagmati');
assert.equal('adminLevel' in retiredPrefix, false);
});
test('long admin names do not publish child-owned geometry hooks', () => {
test('a Zone is retired only in Nepal, where zones were replaced', () => {
const current = selectOutlineResult(
[
boundary({
name: 'Gamo Zone',
addresstype: 'county',
display_name: 'Gamo Zone, South Ethiopia Regional State, Ethiopia',
}),
],
{ kind: 'admin', query: 'Gamo Zone' },
).outline;
assert.equal(current.adminArea, 'Gamo Zone');
assert.equal(current.adminLevel, 'admin2');
const retired = selectOutlineResult(
[
boundary({
name: 'Bagmati Zone',
addresstype: 'state',
display_name: 'Bagmati Zone, Nepal',
}),
],
{ kind: 'admin', query: 'Bagmati Zone' },
).outline;
assert.equal(retired.adminArea, 'Bagmati Zone');
assert.equal('adminLevel' in retired, false);
});
test('admin identity validates the full name before display truncation', () => {
const longValidName = `Province of ${'Example '.repeat(24)}`.trim();
const valid = selectOutlineResult(
[boundary({ name: longValidName, addresstype: 'province' })],
{ kind: 'admin', query: longValidName },
).outline;
assert.equal('adminArea' in valid, false);
assert.equal('adminLevel' in valid, false);
assert.equal(valid.adminArea, longValidName.slice(0, 160));
assert.equal(valid.adminLevel, 'admin1');
const longContradictoryName = `${'Very Long Administrative Name '.repeat(7)}County`;
assert.ok(longContradictoryName.length > 160);
+4 -4
View File
@@ -1137,7 +1137,7 @@ test('the outline route answers OK with polygons and validates its input', async
assert.equal((await request('/elsewhere')).status, 'next');
});
test('the outline route leaves administrative geometry hooks to the annotation layer', async (t) => {
test('the outline route serializes only selected-row administrative identity', async (t) => {
const { fetchImpl } = stubFetch(async (url) => {
const query = new URL(url).searchParams.get('q');
const fixtures = {
@@ -1195,8 +1195,8 @@ test('the outline route leaves administrative geometry hooks to the annotation l
const verified = await request('/outline?q=Bagmati%20Province&kind=admin');
assert.equal(verified.status, 200);
assert.equal('adminArea' in verified.body.outline, false);
assert.equal('adminLevel' in verified.body.outline, false);
assert.equal(verified.body.outline.adminArea, 'Bagmati Province');
assert.equal(verified.body.outline.adminLevel, 'admin1');
for (const query of [
'Northern Region',
@@ -1231,7 +1231,7 @@ test('the outline route leaves administrative geometry hooks to the annotation l
const retired = await request('/outline?q=Bagmati%20Zone&kind=admin');
assert.equal(retired.status, 200);
assert.equal(retired.body.status, 'OK');
assert.equal('adminArea' in retired.body.outline, false);
assert.equal(retired.body.outline.adminArea, 'Bagmati Zone');
assert.equal('adminLevel' in retired.body.outline, false);
const city = await request('/outline?q=Austin&kind=city');
+590
View File
@@ -0,0 +1,590 @@
// THE BOUNDED OSM FEATURE SEARCH — `/api/osm/features`.
//
// A request names a curated preset, a box, a mode and a limit; the server
// writes the Overpass query and sends it only to an operator-configured
// Overpass (OVERPASS_UPSTREAMS). These cases pin what can never reach Overpass:
// unknown kinds, raw tags, oversized boxes, boundary-area scans, and a second
// upstream query while one is running. The generic `/api/overpass` guard is
// covered by its own tests and is not loosened here.
//
// Run with: npm test
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import {
compileOsmFeatureQuery,
OSM_FEATURE_LIMITS,
summarizeOsmFeatures,
} from '../../server/providers/overpass/osmFeatures.js';
import { installOsmFeaturesRoute } from '../../server/providers/overpass/osmFeaturesRoute.js';
import { sanitizeOverpassBody } from '../../server/providers/overpass/query.js';
import { OSM_QUERY_LIMITS } from '../voice/osmActions.js';
test('the server finishes its whole search before the voice answer gives up', () => {
// When the browser stops waiting, the route aborts the upstream query, so
// an OSM_TIMEOUT retry could never be served from the cache.
assert.ok(
OSM_FEATURE_LIMITS.timeoutS * 1000 < OSM_FEATURE_LIMITS.upstreamTimeoutMs,
'Overpass gives up before the server stops waiting for it',
);
assert.ok(
OSM_FEATURE_LIMITS.upstreamTimeoutMs <= OSM_FEATURE_LIMITS.upstreamBudgetMs,
);
assert.ok(
OSM_FEATURE_LIMITS.upstreamBudgetMs + 2_000 <= OSM_QUERY_LIMITS.budgetMs,
`server budget ${OSM_FEATURE_LIMITS.upstreamBudgetMs} ms must end before the client's ${OSM_QUERY_LIMITS.budgetMs} ms`,
);
});
test('a preset and a small box compile to a bounded query', () => {
const compiled = compileOsmFeatureQuery({
preset: 'hospital',
bbox: '85.2,27.6,85.4,27.8',
limit: '50',
});
assert.equal(compiled.ok, true);
assert.equal(
compiled.ql,
'[out:json][timeout:20][maxsize:67108864];(nwr["amenity"="hospital"](27.6,85.2,27.8,85.4););out center tags 50;',
);
assert.doesNotMatch(compiled.ql, /area/, 'no boundary-area scan');
const count = compileOsmFeatureQuery({
preset: 'bar',
bbox: [80, 20, 85, 25],
mode: 'count',
});
assert.match(
count.ql,
/nwr\["amenity"="bar"\].*nwr\["amenity"="pub"\].*out count;$/,
);
});
test('unknown kinds, raw tags, bad and oversized boxes are refused', () => {
assert.equal(
compileOsmFeatureQuery({ preset: 'amenity=hospital', bbox: '0,0,1,1' })
.code,
'UNKNOWN_PRESET',
);
assert.equal(
compileOsmFeatureQuery({ preset: '__proto__', bbox: '0,0,1,1' }).code,
'UNKNOWN_PRESET',
);
assert.equal(
compileOsmFeatureQuery({ preset: 'hospital', bbox: '0,0,1' }).code,
'BAD_BOX',
);
assert.equal(
compileOsmFeatureQuery({ preset: 'hospital', bbox: '0,1,1,0' }).code,
'BAD_BOX',
);
assert.equal(
compileOsmFeatureQuery({ preset: 'hospital', bbox: '0,0,3,3' }).code,
'AREA_TOO_LARGE',
);
assert.equal(
compileOsmFeatureQuery({
preset: 'hospital',
bbox: '0,0,3,3',
mode: 'count',
}).ok,
true,
);
assert.equal(
compileOsmFeatureQuery({
preset: 'hospital',
bbox: '0,0,7,7',
mode: 'count',
}).code,
'AREA_TOO_LARGE',
);
const limited = compileOsmFeatureQuery({
preset: 'hospital',
bbox: '0,0,1,1',
limit: 999999,
});
assert.match(
limited.ql,
new RegExp(`out center tags ${OSM_FEATURE_LIMITS.maxLimit};$`),
);
});
test('a box across the antimeridian is searched as its two halves', () => {
const compiled = compileOsmFeatureQuery({
preset: 'hospital',
bbox: '179.5,-17,-179.5,-16',
});
assert.equal(compiled.ok, true);
assert.match(compiled.ql, /\(-17,179\.5,-16,180\).*\(-17,-180,-16,-179\.5\)/);
});
test('the generic Overpass guard still refuses boundary-area element scans', () => {
const scan =
'data=' +
encodeURIComponent(
'[out:json];area(3600000001)->.a;nwr["amenity"="hospital"](area.a);out;',
);
assert.equal(sanitizeOverpassBody(scan).ok, false);
});
test('answers are compacted: centres, kept tags, truncation and counts', () => {
const compiled = compileOsmFeatureQuery({
preset: 'hospital',
bbox: '0,0,1,1',
limit: 2,
});
const features = summarizeOsmFeatures(
{
elements: [
{
type: 'node',
id: 1,
lat: 0.5,
lon: 0.5,
tags: { name: 'A', amenity: 'hospital', 'contact:phone': 'x' },
},
{
type: 'way',
id: 2,
center: { lat: 0.4, lon: 0.4 },
tags: { name: 'B', amenity: 'hospital' },
},
],
},
compiled,
);
assert.equal(features.count, 2);
assert.equal(features.truncated, true, 'the limit was reached');
assert.deepEqual(features.features[1], {
id: 'way/2',
lat: 0.4,
lon: 0.4,
tags: { name: 'B', amenity: 'hospital' },
});
assert.equal('contact:phone' in features.features[0].tags, false);
const count = summarizeOsmFeatures(
{
elements: [
{
type: 'count',
tags: { nodes: '3', ways: '2', relations: '0', total: '5' },
},
],
},
{ ...compiled, mode: 'count' },
);
assert.equal(count.count, 5);
});
function mount(
fetchPayload,
{
featuresCache,
configured = () => true,
upstreamTag,
upstreamBudgetMs,
} = {},
) {
const routes = new Map();
const writes = [];
const memoryCache = () => {
const held = new Map();
return {
get: async (key) => held.get(key),
set: async (key, value) => {
held.set(key, value);
writes.push(key);
},
};
};
installOsmFeaturesRoute(
{ use: (route, handler) => routes.set(route, handler) },
{
fetchPayload,
cacheRoot: null,
configured,
...(upstreamTag ? { upstreamTag } : {}),
...(upstreamBudgetMs ? { upstreamBudgetMs } : {}),
featuresCache: featuresCache || memoryCache(),
},
);
let client = 0;
const start = (
search,
route = '/api/osm/features',
headers = {},
from = null,
) => {
let req;
let res;
const promise = new Promise((resolve) => {
client += 1;
req = Object.assign(new EventEmitter(), {
method: 'GET',
url: `/?${search}`,
headers,
socket: { remoteAddress: from || `10.9.0.${client}` },
});
res = Object.assign(new EventEmitter(), {
writableEnded: false,
writeHead(status, headers) {
res.status = status;
res.headers = headers;
},
setHeader(name, value) {
res.headers = { ...(res.headers || {}), [name]: value };
},
end(body) {
res.writableEnded = true;
resolve({
status: res.status ?? res.statusCode,
headers: res.headers,
body: JSON.parse(body),
});
},
});
routes.get(route)(req, res);
});
return {
promise,
disconnect() {
res.emit('close');
},
};
};
const request = (search, route = '/api/osm/features', headers, from) =>
start(search, route, headers, from).promise;
return { request, start, writes };
}
test('a search without a limit lists up to 500 features', async () => {
const sent = [];
const { request } = mount(async (body) => {
sent.push(decodeURIComponent(body));
return { status: 200, body: JSON.stringify({ elements: [] }) };
});
for (const search of [
'preset=hospital&bbox=0,0,1,1',
'preset=school&bbox=0,0,1,1&limit=',
]) {
const answer = await request(search);
assert.equal(answer.status, 200, search);
}
assert.equal(sent.length, 2);
for (const ql of sent) assert.match(ql, /out center tags 500;$/);
assert.equal(
compileOsmFeatureQuery({ preset: 'hospital', bbox: '0,0,1,1', limit: '7' })
.limit,
7,
);
});
test('one IPv6 /64 is one client for slots and the rate limit', async () => {
const release = [];
const { request } = mount(async () => {
await new Promise((r) => release.push(r));
return { status: 200, body: JSON.stringify({ elements: [] }) };
});
const first = request(
'preset=hospital&bbox=0,0,1,1',
undefined,
{},
'2001:db8:1:2::a',
);
await new Promise((r) => setTimeout(r, 5));
// A rotated address that wrongly got its own slot would wait upstream
// too: answer every held search before asserting, so a failure never hangs.
const rotated = request(
'preset=school&bbox=0,0,1,1',
undefined,
{},
'2001:db8:1:2:ffff::b',
);
await new Promise((r) => setTimeout(r, 5));
release.splice(0).forEach((r) => r());
assert.equal(
(await rotated).status,
503,
'a rotated address waits for its first search',
);
assert.equal((await first).status, 200);
// Twelve a minute per /64, wherever in it the requests come from.
const { request: limited } = mount(async () => ({
status: 200,
body: JSON.stringify({ elements: [] }),
}));
const statuses = [];
for (let i = 0; i < 13; i += 1)
statuses.push(
(
await limited(
`preset=hospital&bbox=0,0,1,${(i + 1) / 20}`,
undefined,
{},
`2001:db8:5:6::${(i + 1).toString(16)}`,
)
).status,
);
assert.deepEqual(statuses.slice(0, 12), Array(12).fill(200));
assert.equal(statuses[12], 429);
});
test('the route answers, caches, and shares upstream slots fairly', async () => {
const release = [];
let upstream = 0;
const { request, writes } = mount(async (body) => {
upstream += 1;
assert.match(decodeURIComponent(body), /^data=\[out:json\]/);
await new Promise((r) => release.push(r));
return {
status: 200,
body: JSON.stringify({
elements: [{ type: 'node', id: 7, lat: 1, lon: 1, tags: {} }],
}),
};
});
const tick = () => new Promise((r) => setTimeout(r, 5));
const first = request(
'preset=hospital&bbox=0,0,2,2',
undefined,
{},
'10.1.0.1',
);
await tick();
const sameClient = await request(
'preset=school&bbox=0,0,2,2',
undefined,
{},
'10.1.0.1',
);
assert.equal(
sameClient.status,
503,
"one client's second search waits for its first",
);
const shared = request(
'preset=hospital&bbox=0,0,2,2',
undefined,
{},
'10.1.0.2',
);
const second = request(
'preset=school&bbox=0,0,2,2',
undefined,
{},
'10.1.0.3',
);
await tick();
assert.equal(
upstream,
2,
'another client runs alongside; the same search is shared',
);
const third = await request(
'preset=pharmacy&bbox=0,0,2,2',
undefined,
{},
'10.1.0.4',
);
assert.equal(third.status, 503, 'the global cap holds');
release.forEach((r) => r());
const answered = await first;
assert.equal(answered.status, 200);
assert.equal(answered.body.count, 1);
assert.equal((await shared).status, 200);
assert.equal((await second).status, 200);
const again = await request('preset=hospital&bbox=0,0,2,2');
assert.equal(again.headers['X-OSM-Cache'], 'HIT');
assert.equal(upstream, 2);
assert.equal(writes.length, 2);
const refused = await request('preset=hospital&bbox=0,0,9,9');
assert.equal(refused.status, 400);
assert.equal(refused.body.code, 'AREA_TOO_LARGE');
});
test('a hung Overpass is cut off by the overall budget, across every endpoint', async () => {
const { request } = mount(
(_body, _max, { signal }) =>
new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
),
{ upstreamBudgetMs: 30 },
);
const started = Date.now();
const answer = await request('preset=hospital&bbox=0,0,2,2');
assert.ok(Date.now() - started < 1000);
assert.equal(answer.status, 504);
});
test('a cached answer is served only by the Overpass instance that gave it', async () => {
let tag = 'instance-a';
let upstream = 0;
const { request } = mount(
async () => {
upstream += 1;
return {
status: 200,
body: JSON.stringify({
elements: [{ type: 'node', id: 7, lat: 1, lon: 1, tags: {} }],
}),
};
},
{ upstreamTag: () => tag },
);
assert.equal((await request('preset=hospital&bbox=0,0,2,2')).status, 200);
const hit = await request('preset=hospital&bbox=0,0,2,2');
assert.equal(hit.headers['X-OSM-Cache'], 'HIT');
assert.equal(upstream, 1);
tag = 'instance-b';
const switched = await request('preset=hospital&bbox=0,0,2,2');
assert.equal(switched.headers['X-OSM-Cache'], 'MISS');
assert.equal(
upstream,
2,
'the new instance is asked, not the old answer served',
);
});
test('a cross-site page cannot spend the operator Overpass', async () => {
let upstream = 0;
const { request } = mount(async () => {
upstream += 1;
return { status: 200, body: JSON.stringify({ elements: [] }) };
});
for (const headers of [
{ 'sec-fetch-site': 'cross-site', origin: 'https://evil.example' },
{ 'sec-fetch-site': 'cross-site' },
{ origin: 'https://evil.example' },
]) {
const refused = await request('preset=hospital&bbox=0,0,2,2', undefined, {
host: 'localhost:5173',
...headers,
});
assert.equal(refused.status, 403, JSON.stringify(headers));
}
assert.equal(upstream, 0, 'no cross-site request reaches Overpass');
const own = await request('preset=hospital&bbox=0,0,2,2', undefined, {
host: 'localhost:5173',
'sec-fetch-site': 'same-origin',
});
assert.equal(own.status, 200);
// Like /api/overpass, it works behind a reverse proxy: forwarding headers
// alone are not a refusal.
const proxied = await request('preset=school&bbox=0,0,2,2', undefined, {
host: 'localhost:5173',
'sec-fetch-site': 'same-origin',
'x-forwarded-for': '203.0.113.7',
});
assert.equal(proxied.status, 200);
});
test('an upstream refusal is not an empty answer', async () => {
const { request } = mount(async () => ({
status: 429,
body: 'rate_limited',
rateLimited: true,
}));
const answer = await request('preset=hospital&bbox=0,0,1,1');
assert.equal(answer.status, 429);
assert.ok(answer.body.error);
});
test('malformed successful answers are refused and never cached', async () => {
const payloads = [
{},
{ elements: [{ type: 'count', tags: { nodes: '2' } }] },
{ elements: [{ type: 'way', id: 9, tags: { amenity: 'hospital' } }] },
];
const { request, writes } = mount(async () => ({
status: 200,
body: JSON.stringify(payloads.shift()),
}));
const missingElements = await request('preset=hospital&bbox=0,0,1,1');
const missingTotal = await request('preset=hospital&bbox=0,0,1,1&mode=count');
const unusableRows = await request('preset=hospital&bbox=1,1,2,2');
for (const answer of [missingElements, missingTotal, unusableRows]) {
assert.equal(answer.status, 502);
assert.match(answer.body.error, /failed/i);
}
assert.deepEqual(writes, []);
});
test('the last disconnected waiter aborts upstream work and frees the slot', async () => {
let calls = 0;
let firstStarted;
let firstAborted;
const started = new Promise((resolve) => {
firstStarted = resolve;
});
const aborted = new Promise((resolve) => {
firstAborted = resolve;
});
const { request, start } = mount(async (_body, _cap, { signal }) => {
calls += 1;
if (calls === 1) {
firstStarted();
return new Promise((resolve, reject) => {
signal.addEventListener(
'abort',
() => {
firstAborted();
reject(signal.reason);
},
{ once: true },
);
});
}
return { status: 200, body: '{"elements":[]}' };
});
const abandoned = start('preset=hospital&bbox=0,0,1,1');
await started;
abandoned.disconnect();
await aborted;
await new Promise((resolve) => setImmediate(resolve));
const next = await request('preset=school&bbox=0,0,1,1');
assert.equal(next.status, 200);
assert.equal(calls, 2);
});
test('one disconnected coalesced waiter does not cancel a live sibling', async () => {
let release;
let upstreamSignal;
let calls = 0;
const { start } = mount(async (_body, _cap, { signal }) => {
calls += 1;
upstreamSignal = signal;
await new Promise((resolve) => {
release = resolve;
});
return { status: 200, body: '{"elements":[]}' };
});
const first = start('preset=hospital&bbox=0,0,1,1');
await new Promise((resolve) => setImmediate(resolve));
const second = start('preset=hospital&bbox=0,0,1,1');
await new Promise((resolve) => setImmediate(resolve));
first.disconnect();
assert.equal(upstreamSignal.aborted, false);
release();
const answer = await second.promise;
assert.equal(answer.status, 200);
assert.equal(calls, 1);
});
test('without a configured Overpass the route refuses and fetches nothing', async () => {
let upstream = 0;
const { request } = mount(
async () => {
upstream += 1;
return { status: 200, body: '{"elements":[]}' };
},
{ configured: () => false },
);
const answer = await request('preset=hospital&bbox=85.2,27.6,85.4,27.8');
assert.equal(answer.status, 503);
assert.equal(answer.body.code, 'OVERPASS_NOT_CONFIGURED');
assert.equal(answer.body.retryable, false);
assert.match(answer.body.error, /configured Overpass/);
assert.equal(upstream, 0);
});
+12 -13
View File
@@ -18,7 +18,7 @@ import {
call,
} from '../../scripts/voice-bench/grade.mjs';
import {
CORE_PHRASES, COVERAGE_PROBE, DIALOGUES, ROUTING_PHRASES, CAPABILITY_EXTRAS,
CORE_PHRASES, COVERAGE_PROBE, DIALOGUES, ROUTING_PHRASES, GEOMETRY_PHRASES, OVERPASS_PHRASES, CAPABILITY_EXTRAS,
dialogueResult, coverageRubric,
} from '../../scripts/voice-bench/phrases.mjs';
import { stubResult } from '../../scripts/voice-bench/stub.mjs';
@@ -293,10 +293,11 @@ test('coverage v2 grades the same turn against the current tool surface', () =>
assert.equal(COVERAGE_PROBE.every((c) => c.v2 && typeof c.v2.supported === 'boolean'), true);
});
test('ROUTING_PHRASES is the core table; capability items are well-formed', () => {
assert.equal(ROUTING_PHRASES, CORE_PHRASES);
test('ROUTING_PHRASES is the core table plus geometry and place search; capability items are well-formed', () => {
assert.deepEqual(ROUTING_PHRASES, [...CORE_PHRASES, ...GEOMETRY_PHRASES, ...OVERPASS_PHRASES]);
assert.ok(OVERPASS_PHRASES.every((p) => p.requires?.includes('osm_query')), 'place search is gated');
const names = new Set(GEV_REALTIME_TOOLS.map((t) => t.name));
for (const p of CAPABILITY_EXTRAS.filter((c) => !c.turns)) {
for (const p of [...GEOMETRY_PHRASES, ...OVERPASS_PHRASES, ...CAPABILITY_EXTRAS.filter((c) => !c.turns)]) {
const expected = typeof p.expect === 'string' ? [p.expect] : p.expect.oneOf;
for (const n of expected) assert.ok(names.has(n), `${p.phrase} expects unknown tool ${n}`);
}
@@ -310,16 +311,14 @@ test('ROUTING_PHRASES is the core table; capability items are well-formed', () =
assert.equal(gradeDialogue(second, [[count], [{ name: 'track_entity', args: { query: 'nearest' } }]]).ok, false);
});
test('coverage v2: a count for the removed drawn-area scope is never graded correct', () => {
test('coverage v2: a drawn-area count must use the drawn scope, never the view', () => {
const drawn = coverageRubric(coverage('fires-in-drawn-area'), 'v2');
for (const kind of ['view', 'drawn']) {
const verdict = gradeCoverage(drawn, [{ name: 'analyst_query', args: { layers: ['local-firms'], scope: { kind } } }], '12 fires inside the area you drew.');
assert.notEqual(verdict.verdict, 'correct');
assert.equal(verdict.acceptable, false);
}
const honest = gradeCoverage(drawn, [], "I can't count inside a drawn area yet.");
assert.equal(honest.verdict, 'honest-refusal');
assert.equal(honest.acceptable, true);
const scoped = (kind) => gradeCoverage(drawn, [{ name: 'analyst_query', args: { layers: ['local-firms'], scope: { kind } } }], '12 fires inside the area you drew.');
assert.equal(scoped('drawn').verdict, 'correct');
assert.notEqual(scoped('view').verdict, 'correct');
const bagmati = CAPABILITY_EXTRAS.find((c) => c.id === 'bagmati-then-fires');
const fires = { name: 'analyst_query', args: { layers: ['local-firms'], scope: { kind: 'area', areaId: 'area-1' } } };
assert.equal(gradeDialogue(bagmati, [[{ name: 'resolve_area', args: { query: 'Bagmati province' } }], [fires]]).ok, true);
});
+1
View File
@@ -30,6 +30,7 @@ export const LAYER_NAME_KEYS = Object.freeze({
'telegeography-submarine-cables': msg('Submarine Cables'),
'local-firms': msg('FIRMS Active Fires'),
'local-adsb': msg('Local ADS-B'),
'osm-places': msg('OSM Places'),
transit: msg('Transit'),
'recent-imagery': msg('Recent Imagery'),
'fire-perimeters': msg('Fire Perimeters'),
+1 -1
View File
@@ -94,7 +94,7 @@ const PANEL_GROUPS = [
// needs no heading there.
{
label: msg('Utilities'),
ids: ['directions'],
ids: ['directions', 'osm-places'],
containerId: 'utilities-toggles',
},
{
+1
View File
@@ -37,6 +37,7 @@ const ESRI_NOTE = msg('Imagery on Esri · Google 3D returns when cleared');
const LAYER_NOTICES = new Set([
ESRI_NOTE,
msg('Box and images cleared'),
msg('Search cancelled'),
msg('Esri map unavailable · no swipe'),
msg('Swipe needs a globe map'),
msg('Imagery stays on Esri · CLEAR to use Google 3D'),
+91 -1
View File
@@ -1,4 +1,8 @@
import { HUD_LAYOUTS } from '../hudLayoutPolicy.js';
import {
AREA_CANDIDATE_ID_PATTERN,
MAX_AREA_CANDIDATE_ID_LENGTH,
} from './areaCandidateContract.js';
// Canonical action arguments. Descriptive wording is supplied separately.
import {
@@ -788,11 +792,28 @@ const schemas = [
properties: {
kind: {
type: 'string',
enum: ['view', 'region', 'radius', 'anywhere', 'pointer'],
enum: [
'view',
'region',
'radius',
'anywhere',
'pointer',
'area',
'drawn',
'annotation',
],
},
name: {
type: 'string',
},
areaId: {
type: 'string',
maxLength: 120,
},
id: {
type: 'string',
maxLength: 60,
},
km: {
type: 'number',
},
@@ -849,6 +870,75 @@ const schemas = [
},
},
},
{
name: 'resolve_area',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
query: { type: 'string', minLength: 1, maxLength: 200 },
level: {
type: 'string',
enum: [
'country',
'admin1',
'admin2',
'city',
'district',
'natural',
'site',
],
},
within: { type: 'string', maxLength: 120 },
candidateId: {
type: 'string',
minLength: 1,
maxLength: MAX_AREA_CANDIDATE_ID_LENGTH,
pattern: AREA_CANDIDATE_ID_PATTERN,
},
draw: { type: 'boolean' },
around: { type: 'boolean' },
radiusM: { type: 'number', minimum: 50, maximum: 20000 },
flyTo: { type: 'boolean' },
},
},
},
{
name: 'find_imagery',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
areaId: { type: 'string', maxLength: 120 },
area: { type: 'string', enum: ['view', 'pointer'] },
dateRange: {
type: 'object',
additionalProperties: false,
properties: {
from: { type: 'string', maxLength: 30 },
to: { type: 'string', maxLength: 30 },
days: { type: 'number', minimum: 1, maximum: 30 },
},
},
maxCloudPct: { type: 'number', minimum: 0, maximum: 100 },
source: { type: 'string', enum: ['hls', 'viirs', 'any'] },
},
},
},
{
name: 'osm_query',
parameters: {
type: 'object',
additionalProperties: false,
required: ['what'],
properties: {
what: { type: 'string', minLength: 1, maxLength: 80 },
areaId: { type: 'string', maxLength: 120 },
countOnly: { type: 'boolean' },
limit: { type: 'number', minimum: 1, maximum: 1000 },
},
},
},
{
name: 'next_iss_pass',
parameters: {
+48 -1
View File
@@ -4,6 +4,9 @@ import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { GEV_ACTION_SCHEMAS, createActionTools } from './actionSchemas.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { createAreaStore } from '../data/areaStore.js';
import { createAreaResolver } from '../data/areaResolver.js';
import { findAdminCandidates } from '../data/adminBoundaries.js';
import {
validateArgs,
validateCall,
@@ -49,7 +52,10 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// Snow leaves the set_visual_style enum.
// The Operator HUD layout leaves set_hud's layout enum.
// The layer alias hint leaves out aliases the layer id already spells.
'7f113854cdfe3114b9edd69f49995502276c718ce8878320161a68bafb63ebe2',
// Voice geometry adds resolve_area, find_imagery, osm_query, the analyst
// area scopes and the osm-places layer; natural-region candidates and the
// tightened area tool wording follow review.
'd0c5ad733670972713a54cb10676c42b7c6e78834bfff52b473c0e8a45e742c3',
);
});
@@ -79,6 +85,37 @@ test('descriptions customize wording without changing immutable shared arguments
);
});
test('every emitted area candidate round-trips through the production schema', async () => {
const candidateSchema = GEV_ACTION_SCHEMAS.find(
(tool) => tool.name === 'resolve_area',
).parameters.properties.candidateId;
const resolver = createAreaResolver({
store: createAreaStore(),
findAdminCandidates,
});
const ambiguous = await resolver.resolve({ query: 'Georgia' });
assert.equal(ambiguous.needsClarification, true);
for (const choice of ambiguous.candidates) {
assert.ok(choice.candidateId.length >= candidateSchema.minLength);
assert.ok(choice.candidateId.length <= candidateSchema.maxLength);
assert.match(choice.candidateId, new RegExp(candidateSchema.pattern, 'u'));
assert.equal(
(await resolver.resolve({ candidateId: choice.candidateId })).ok,
true,
);
}
assert.equal(ambiguous.candidates[0].candidateId.length, 44);
const grammar = new RegExp(candidateSchema.pattern, 'u');
for (const malformed of [
'ne:state:bad slug',
'ne:state:../../etc',
'ne:state:!!!',
'ne:state:a_b',
])
assert.doesNotMatch(malformed, grammar);
assert.ok(`ne:state:${'x'.repeat(80)}`.length > candidateSchema.maxLength);
});
test('metadata cannot add tools, fields, types or enum values', () => {
for (const descriptions of [
{ execute_shell: { description: 'not an action' } },
@@ -112,6 +149,9 @@ test('all legacy action arguments are byte-identical after removing the delibera
'next_satellite_pass',
'set_cyber_sonar',
'set_voice_inactivity_timeout',
'resolve_area',
'find_imagery',
'osm_query',
].includes(tool.name),
);
// Layer enums are generated from the voice layer manifest and pinned by
@@ -164,6 +204,13 @@ test('all legacy action arguments are byte-identical after removing the delibera
analystScopeKind.enum = analystScopeKind.enum.filter(
(key) => key !== 'pointer',
);
// Area handles add three scope kinds and their two identifiers.
const scope = property('analyst_query').scope;
scope.properties.kind.enum = scope.properties.kind.enum.filter(
(kind) => !['area', 'drawn', 'annotation'].includes(kind),
);
delete scope.properties.areaId;
delete scope.properties.id;
// The analyst centre now requires a real coordinate.
const center = property('analyst_query').scope.properties.center;
delete center.required;
+843
View File
@@ -0,0 +1,843 @@
/**
* Voice area handles: `resolve_area` and the area scopes of `analyst_query`.
*
* One area store per action runner holds every area a turn can name — places
* resolved by name, landmark areas drawn "around X", and annotation outlines
* (the draw tool's areas and voice outlines). The model gets an `areaId` and a
* summary; the geometry stays here.
*
* Names resolve through the same outline ladder as `annotate_map` (bundled
* packs, the guarded Nominatim outline route, OpenFreeMap tiles, and
* Overpass only when an operator configured it) — see data/areaResolver.js.
*
* Drawing uses the annotation engine with a simplified display copy. Counting
* always reads the stored geometry, so a simplified outline never changes a
* count.
*
* @module voice/areaActions
*/
import { createAreaStore, summarizeArea } from '../data/areaStore.js';
import { createAreaResolver, foldLatinMarks } from '../data/areaResolver.js';
import { circleRing, displayParts } from '../data/areaGeometry.js';
import { findNaturalRegion } from '../data/naturalEarthRegions.js';
import {
adminQueryIdentity,
findAdminCandidates as findBundledAdminCandidates,
} from '../data/adminBoundaries.js';
import {
NATURAL_EARTH_CREDIT,
US_CENSUS_CREDIT,
WOF_CREDIT,
registerDynamicCredit,
} from '../data/dataCredits.js';
import { isUnavailableCapability } from '../sources/capability.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js';
/** A spoken resolve waits this long before answering "still looking". */
const RESOLVE_BUDGET_MS = 8_000;
/** An analyst region scope waits this long (the base region-scope budget). */
const REGION_BUDGET_MS = 3_000;
/** Finding the landmark itself gives up after this. */
const AROUND_BUDGET_MS = 10_000;
/** The enclosing area gets this long before the buffer is used instead. */
const AROUND_OUTLINE_MS = 6_000;
/** Buffer radius around a landmark with no enclosing polygon. */
const AROUND_DEFAULT_RADIUS_M = 250;
const AROUND_MAX_RADIUS_M = 20_000;
/** What the annotation resolver should treat a named area as. */
const ENTITY_KIND = Object.freeze({
country: 'country',
admin1: 'state',
admin2: 'county',
district: 'district',
site: 'compound',
});
/** How each area source is named on screen. */
const SOURCE_LABELS = Object.freeze({
'natural-earth': 'Natural Earth',
'us-census': 'US Census',
wof: "Who's On First",
datasf: 'DataSF',
osm: 'OpenStreetMap',
openfreemap: 'OpenStreetMap via OpenFreeMap',
drawn: 'drawn by hand',
annotation: 'map outline',
approximate: 'approximate outline',
});
const ADMIN_IDENTITY_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 administrativeIdentity(value) {
const normalized = String(value || '')
.normalize('NFKC')
.toLocaleLowerCase()
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
.trim();
// Forms the administrative matcher reads as one unit's name: "State of
// Texas" is Texas at admin level 1, "USA" is United States of America.
const named = adminQueryIdentity(value);
if (named?.level === 'admin1')
return {
name: foldLatinMarks(normalized.replace(/^(?:the )?.+? of /, '')),
level: 'admin1',
};
if (named?.level === 'country')
return { name: foldLatinMarks(named.name), level: 'country' };
const match = normalized.match(
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/,
);
return {
// Compared, never shown: "Quebec" as spoken names "Québec".
name: foldLatinMarks(
match ? normalized.slice(0, -match[0].length).trim() : normalized,
),
level: match ? ADMIN_IDENTITY_LEVEL[match[1]] || null : null,
};
}
/** Outline ladder source → area source and rung. */
function outlineOrigin(outlineSource) {
switch (outlineSource) {
case 'natural-earth':
case 'us-census':
case 'wof':
case 'datasf':
return { source: outlineSource, rung: 'bundled' };
case 'openfreemap':
return { source: 'openfreemap', rung: 'openfreemap' };
case 'nominatim':
return { source: 'osm', rung: 'nominatim' };
default:
// The base ladder's own OSM rungs run only on an operator Overpass.
return { source: 'osm', rung: 'overpass' };
}
}
/** Round an area for speech: "about 20,300 km²". */
export function spokenArea(km2) {
if (!Number.isFinite(km2) || km2 <= 0) return null;
if (km2 < 1) return `about ${Math.round(km2 * 100) / 100} km²`;
const digits = Math.max(0, Math.floor(Math.log10(km2)) - 1);
const rounded = Math.round(km2 / 10 ** digits) * 10 ** digits;
return `about ${rounded.toLocaleString('en-US')} km²`;
}
const cancelledResult = () => ({
ok: false,
code: 'CANCELLED',
cancelled: true,
error: 'Superseded.',
});
/**
* Build the runner's area services.
* @param {object} options
* @param {object} options.viewer
* @param {object|null} options.annotations Annotation engine.
* @param {object} [options.placeSearch]
* @param {object} [options.annotationResolver] The annotation outline ladder.
*/
export function createVoiceAreas({
viewer,
annotations = null,
placeSearch = unavailablePlaceSearch,
annotationResolver = null,
findRegion = findNaturalRegion,
findAdminCandidates = findBundledAdminCandidates,
aroundOutlineMs = AROUND_OUTLINE_MS,
} = {}) {
// An area an outline on the map still refers to is never evicted: its
// counting geometry must outlive the display copy drawn from it.
const store = createAreaStore({
isPinned: (areaId) =>
typeof annotations?.list === 'function' &&
annotations.list().some((anno) => anno?.areaId === areaId),
});
/** The ground at the centre of the view, else the camera's position. */
function viewCenter() {
const picked = annotationResolver?.pickWorldFromScreen?.(viewer, 0.5, 0.5);
if (Number.isFinite(picked?.lat) && Number.isFinite(picked?.lon))
return picked;
const carto = viewer?.camera?.positionCartographic;
return carto
? {
lat: (carto.latitude * 180) / Math.PI,
lon: (carto.longitude * 180) / Math.PI,
}
: null;
}
/**
* The outline ladder for one resolved anchor, awaited with a budget.
* Resolves the resolver's tri-state outline, or `{timeout: true}`.
*/
async function awaitOutline(resolved, budgetMs) {
if (resolved?.ring && !resolved.resolveOutline) return resolved;
if (typeof resolved?.resolveOutline !== 'function') return null;
if (!Number.isFinite(budgetMs))
return resolved.resolveOutline().catch(() => undefined);
let timer;
try {
return await Promise.race([
resolved.resolveOutline().catch(() => undefined),
new Promise((resolveTimeout) => {
timer = setTimeout(() => resolveTimeout({ timeout: true }), budgetMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
/**
* A named place through the annotation outline ladder: bundled packs near
* the view or confirmed by the geocoder, then the guarded Nominatim
* outline, then an operator Overpass when configured.
*/
async function resolveNamedOutline(query, { level, levelHint, signal }) {
if (typeof annotationResolver?.resolveAnnotationTarget !== 'function')
return {
ok: false,
code: 'AREA_UNAVAILABLE',
error: 'Boundary lookup is unavailable here.',
};
let resolved;
try {
resolved = await annotationResolver.resolveAnnotationTarget({
placeSearch,
viewer,
target: query,
footprint: true,
deferFootprint: true,
entityKind: ENTITY_KIND[level || levelHint] || null,
allowDistant: true,
signal,
});
} catch (error) {
if (signal?.aborted) throw error;
resolved = null;
}
if (!resolved)
return {
ok: false,
code: 'AREA_NOT_FOUND',
error: `Could not find "${query}".`,
};
const directBundledOutline = Boolean(
resolved?.ring &&
!resolved.resolveOutline &&
resolved.source === 'bundled',
);
const outline = await awaitOutline(resolved, Infinity);
signal?.throwIfAborted?.();
if (!outline?.ring || outline.ring.length < 3) {
if (outline === null)
return {
ok: false,
code: 'AREA_NOT_FOUND',
error: `No boundary found for "${query}".`,
};
return {
ok: false,
code: 'AREA_UNAVAILABLE',
error: isUnavailableCapability(outline)
? `No outline source can trace "${query}" here — it stays a point.`
: `The boundary for "${query}" is not available right now — try again shortly.`,
};
}
const { source, rung } = outlineOrigin(outline.outlineSource);
const primaryLabel =
String(resolved.label || query)
.split(',')[0]
.trim() || query;
const requestedLabel = String(query).split(',')[0].trim();
const requestedLevel = level || levelHint || null;
const administrativeRequest = ['country', 'admin1', 'admin2'].includes(
requestedLevel,
);
const administrativeLabel =
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/i.test(
primaryLabel,
);
const sourceIdentity = String(
outline.adminArea || (directBundledOutline ? resolved.label : ''),
)
.split(',')[0]
.trim();
const sourceLevel = String(outline.adminLevel || '').trim();
const sourceAdmin = administrativeIdentity(sourceIdentity);
const primaryAdmin = administrativeIdentity(primaryLabel);
const requestedAdmin = administrativeIdentity(requestedLabel);
// The server leaves a Zone without a level only where zones were retired
// (Nepal). A verified level means a current zone, so "Zone" claims no level.
if (sourceLevel && sourceAdmin.level === 'retired') {
for (const admin of [sourceAdmin, primaryAdmin, requestedAdmin])
if (admin.level === 'retired') admin.level = null;
}
const sourceClaimsAdministrative = Boolean(
administrativeRequest ||
administrativeLabel ||
outline.adminArea ||
sourceAdmin.level,
);
const expectedAdministrativeLevel =
requestedLevel || primaryAdmin.level || requestedAdmin.level || '';
const verifiedSourceLevel =
sourceLevel ||
(directBundledOutline &&
sourceAdmin.level &&
sourceAdmin.level !== 'retired'
? sourceAdmin.level
: '');
if (sourceAdmin.level === 'retired') {
return {
ok: false,
code: 'AREA_IDENTITY_MISMATCH',
error: `The boundary source returned ${sourceIdentity}, a retired administrative identity.`,
};
}
if (outline.synthesized && sourceClaimsAdministrative) {
return {
ok: false,
code: 'AREA_IDENTITY_UNVERIFIED',
error: `The available outline for "${query}" is only an approximation, not a verified administrative boundary.`,
};
}
if (sourceClaimsAdministrative && !sourceIdentity) {
return {
ok: false,
code: 'AREA_IDENTITY_UNVERIFIED',
error: `The boundary source did not verify which administrative area "${query}" names.`,
};
}
if (sourceIdentity && sourceAdmin.name !== primaryAdmin.name) {
return {
ok: false,
code: 'AREA_IDENTITY_MISMATCH',
error: `The boundary source returned ${sourceIdentity}, not ${primaryLabel}.`,
};
}
if (
administrativeRequest &&
sourceIdentity &&
sourceAdmin.name !== requestedAdmin.name
) {
return {
ok: false,
code: 'AREA_IDENTITY_MISMATCH',
error: `The boundary source returned ${sourceIdentity}, not ${requestedLabel}.`,
};
}
if (
sourceClaimsAdministrative &&
sourceAdmin.level &&
expectedAdministrativeLevel &&
sourceAdmin.level !== expectedAdministrativeLevel
) {
return {
ok: false,
code: 'AREA_IDENTITY_MISMATCH',
error: `The boundary source returned ${sourceIdentity}, not ${expectedAdministrativeLevel}.`,
};
}
if (sourceClaimsAdministrative && !verifiedSourceLevel) {
return {
ok: false,
code: 'AREA_IDENTITY_UNVERIFIED',
error: `The boundary source did not verify the administrative level for "${query}".`,
};
}
if (
sourceClaimsAdministrative &&
expectedAdministrativeLevel &&
verifiedSourceLevel !== expectedAdministrativeLevel
) {
return {
ok: false,
code: 'AREA_IDENTITY_MISMATCH',
error: `The boundary source returned ${sourceLevel}, not ${expectedAdministrativeLevel}.`,
};
}
const polygons =
Array.isArray(outline.polygons) && outline.polygons.length
? outline.polygons
: [[outline.ring]];
return {
ok: true,
name: sourceIdentity || primaryLabel,
...(sourceIdentity ? { adminName: sourceIdentity } : {}),
...(verifiedSourceLevel ? { adminLevel: verifiedSourceLevel } : {}),
polygons,
source,
rung,
approximate: Boolean(outline.synthesized),
...(outline.synthesized ? { basis: 'buffer sized to the place' } : {}),
};
}
const resolver = createAreaResolver({
store,
findNaturalRegion: findRegion,
findAdminCandidates,
resolveNamedOutline,
viewCenter,
onCredit: (source) => {
const credit = {
'natural-earth': NATURAL_EARTH_CREDIT,
'us-census': US_CENSUS_CREDIT,
wof: WOF_CREDIT,
}[source];
if (credit) registerDynamicCredit(viewer, credit);
},
});
const boardGeneration = () =>
typeof annotations?.generation === 'function'
? annotations.generation()
: 0;
/** Draw a stored area through the annotation engine. */
async function drawArea(
record,
{
flyTo = true,
color = 'cyan',
signal = null,
isCurrent = () => true,
} = {},
) {
if (signal?.aborted || !isCurrent())
return { drawn: false, cancelled: true };
if (typeof annotations?.annotate !== 'function')
return { drawn: false, reason: 'Annotation engine unavailable' };
const shown = displayParts(record.geometry, {
// Draped outlines are tessellated against the terrain and tiles; a few
// hundred vertices per area keeps drawing fast. Counting is unaffected.
maxVertices: 1200,
maxParts: 24,
});
if (!shown.parts.length) return { drawn: false, reason: 'Nothing to draw' };
const polygons = shown.parts.map((part) => [part.outer, ...part.holes]);
const result = await annotations.annotate(
[
{
type: 'area',
manual: true,
origin: 'area',
areaId: record.areaId,
ring: polygons[0][0],
polygons,
label: record.name,
color,
approximate: record.approximate,
},
],
{
persist: true,
flyTo,
clearPrevious: false,
signal,
isCurrent,
},
);
if (signal?.aborted || !isCurrent() || result?.aborted)
return { drawn: false, cancelled: true };
const item = (result?.results || [])[0];
const drawn = Boolean(item?.ok);
const reason = drawn
? null
: result?.capped
? 'annotation limit reached'
: item?.error || result?.error || 'the outline could not be added';
return {
drawn,
annotationId: item?.id || null,
...(reason ? { reason } : {}),
...(shown.drawnParts < shown.totalParts
? { drawnParts: shown.drawnParts, totalParts: shown.totalParts }
: {}),
};
}
/** Area-like annotations, newest first. */
function areaAnnotations(predicate = () => true) {
const list =
typeof annotations?.list === 'function' ? annotations.list() : [];
return list
.filter(
(anno) =>
Array.isArray(anno?.ring) && anno.ring.length >= 3 && predicate(anno),
)
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
}
/**
* The area an outline on the map stands for. An outline drawn from an area
* handle counts over that handle's full geometry, never over its simplified
* drawing; when the handle is gone (the page reloaded, so the store is
* new while the persisted outline remains) the answer is AREA_EXPIRED,
* not a count over the drawing. A hand-drawn outline IS its geometry. A
* voice annotation outline counts over every part it drew and is marked
* approximate when it was a buffer.
* @returns {object} A stored record, or a refusal with `code`.
*/
function areaForAnnotation(anno) {
if (anno.areaId) {
const stored = store.get(anno.areaId);
if (stored) return stored;
return {
code: 'AREA_EXPIRED',
error: `The outline of ${anno.label || 'that area'} was drawn before this page loaded — resolve it again to count inside it.`,
};
}
const drawn = anno.origin === 'drawn';
const geometry =
Array.isArray(anno.polygons) && anno.polygons.length
? anno.polygons
: [[anno.ring]];
return (
store.put({
geometry,
name: anno.label || (drawn ? 'the drawn area' : 'the outlined area'),
source: drawn ? 'drawn' : 'annotation',
level: drawn ? 'drawn' : null,
sourceId: `anno:${anno.id}`,
approximate: Boolean(anno.synthesized),
}) || {
code: 'AREA_UNKNOWN',
error: 'That outline is not a usable area.',
}
);
}
/**
* Provider for analyst_query's `area`, `drawn` and `annotation` scopes.
* @param {{kind: string, areaId?: string, id?: string}} scope
*/
async function resolveAreaScope(scope) {
const found = (record) =>
record?.code
? { ok: false, code: record.code, error: record.error }
: record
? {
ok: true,
area: {
areaId: record.areaId,
name: record.name,
prepared: record.prepared,
source: record.source,
sourceLabel: SOURCE_LABELS[record.source] || record.source,
approximate: record.approximate,
},
}
: null;
if (scope.kind === 'area') {
return (
found(store.get(scope.areaId)) || {
ok: false,
code: 'AREA_UNKNOWN',
error: `No area "${scope.areaId}" on this page — call resolve_area again.`,
}
);
}
if (scope.kind === 'drawn') {
const anno = areaAnnotations((a) => a.origin === 'drawn')[0];
return (
found(anno && areaForAnnotation(anno)) || {
ok: false,
code: 'NO_DRAWN_AREA',
error:
'Nothing is drawn on the map — draw an area with the draw tool first.',
}
);
}
const anno = scope.id
? areaAnnotations((a) => a.id === scope.id)[0]
: areaAnnotations()[0];
return (
found(anno && areaForAnnotation(anno)) || {
ok: false,
code: 'NO_AREA_ANNOTATION',
error: scope.id
? `Annotation ${scope.id} is not an outlined area.`
: 'No outlined area is on the map.',
}
);
}
/**
* Provider for analyst_query's `region` scope: the same ladder, the base
* 3 s budget, answers in the engine's region shape.
*/
async function resolveRegion(name, { signal } = {}) {
const result = await resolver.resolve(
{ query: name },
{ signal, budgetMs: REGION_BUDGET_MS },
);
if (result.ok)
return {
name: result.record.name,
prepared: result.record.prepared,
geometry: result.record.geometry,
source: result.record.source,
sourceLabel:
SOURCE_LABELS[result.record.source] || result.record.source,
areaId: result.record.areaId,
approximate: result.record.approximate,
};
if (result.needsClarification) return result;
if (result.code === 'AREA_TIMEOUT')
return { name, ring: null, error: 'region-timeout' };
return null;
}
/** "An area around <landmark>": its enclosing area, else a buffer. */
async function resolveAround(args, { signal }) {
const target = String(args.query || '').trim();
if (
!target ||
typeof annotationResolver?.resolveAnnotationTarget !== 'function'
)
return { ok: false, code: 'BAD_AREA', error: 'Name a landmark.' };
const budget = AbortSignal.timeout(AROUND_BUDGET_MS);
const combined = signal ? AbortSignal.any([signal, budget]) : budget;
// A radius the user gave always draws a buffer, so the enclosing outline
// (and its Nominatim request) is never looked up for it.
const explicitRadius =
args.radiusM !== undefined &&
args.radiusM !== null &&
Number.isFinite(Number(args.radiusM));
let resolved;
let outline = null;
try {
// The anchor first; the enclosing outline (Nominatim grounds, then the
// OpenFreeMap area holding it) is a bounded extra.
resolved = await annotationResolver.resolveAnnotationTarget({
placeSearch,
viewer,
target,
footprint: true,
deferFootprint: true,
entityKind: 'compound',
allowDistant: true,
signal: combined,
});
outline =
resolved && !explicitRadius
? await awaitOutline(resolved, aroundOutlineMs)
: null;
} catch {
resolved = null;
}
if (signal?.aborted) return cancelledResult();
if (!resolved) {
return budget.aborted
? {
ok: false,
code: 'AREA_TIMEOUT',
error: `Finding ${target} is taking a while — ask again in a moment.`,
}
: {
ok: false,
code: 'AREA_NOT_FOUND',
error: `Could not find ${target}.`,
};
}
const radiusM = Math.max(
50,
Math.min(
AROUND_MAX_RADIUS_M,
Number(args.radiusM) || AROUND_DEFAULT_RADIUS_M,
),
);
// A real enclosing outline counts with every part it came with; anything
// else (no outline, a sized buffer, a slow lookup) is a labelled buffer.
const exact =
!explicitRadius &&
Array.isArray(outline?.ring) &&
outline.ring.length >= 3 &&
!outline.synthesized;
const origin = exact ? outlineOrigin(outline.outlineSource) : null;
const geometry = exact
? Array.isArray(outline.polygons) && outline.polygons.length
? outline.polygons
: [[outline.ring]]
: [[circleRing(resolved.lat, resolved.lon, radiusM)]];
// The landmark as the user named it ("Ferry Building"), not the geocoder's
// street address.
const name = String(target.split(',')[0].trim() || target).slice(0, 120);
const record = store.put({
geometry,
name,
source: exact ? origin.source : 'approximate',
level: 'site',
approximate: !exact,
meta: {
around: target,
rung: exact ? origin.rung : 'buffer',
...(exact ? {} : { radiusM }),
basis: exact
? 'enclosing mapped area'
: `${radiusM} m around the landmark`,
},
});
if (!record)
return {
ok: false,
code: 'AREA_NOT_FOUND',
error: `Could not outline ${target}.`,
};
return {
ok: true,
record,
confidence: exact ? 0.8 : 0.5,
rung: exact ? origin.rung : 'buffer',
};
}
/**
* The `resolve_area` action.
* @param {object} args Tool arguments.
* @param {{signal?: AbortSignal, isCurrent?: () => boolean, progress?: Function}} [runOptions]
* `progress(step, label)` reports `resolve` then `draw` for narration.
*/
async function resolveAreaAction(args = {}, runOptions = {}) {
const current = () =>
!runOptions.signal?.aborted &&
(typeof runOptions.isCurrent !== 'function' || runOptions.isCurrent());
const progress = (step, label) => {
if (current()) runOptions.progress?.(step, label);
};
progress('resolve', String(args.query || ''));
const started = Date.now();
// The board as it was when the user asked: a clear while the lookup runs
// means the outline (and its camera move) is no longer wanted.
const boardAtStart = boardGeneration();
const result = args.around
? await resolveAround(args, { signal: runOptions.signal })
: await resolver.resolve(
{
query: args.query,
level: args.level,
within: args.within,
candidateId: args.candidateId,
},
{ signal: runOptions.signal, budgetMs: RESOLVE_BUDGET_MS },
);
if (!current())
return {
ok: false,
action: 'resolve_area',
code: 'CANCELLED',
cancelled: true,
error: 'This request was superseded.',
};
if (!result.ok) {
return {
ok: false,
action: 'resolve_area',
code: result.code || 'AREA_NOT_FOUND',
error: result.error,
...(result.needsClarification
? {
needsClarification: true,
query: String(args.query || ''),
candidates: result.candidates,
}
: {}),
};
}
const { record } = result;
const draw = args.draw === true || Boolean(args.around);
const boardCleared = draw && boardGeneration() !== boardAtStart;
if (draw && !boardCleared) progress('draw', record.name);
const drawing =
draw && !boardCleared
? await drawArea(record, {
flyTo: args.flyTo !== false,
signal: runOptions.signal,
isCurrent: current,
})
: null;
if (!current() || drawing?.cancelled)
return {
ok: false,
action: 'resolve_area',
code: 'CANCELLED',
cancelled: true,
error: 'This request was superseded.',
};
const size = spokenArea(record.areaKm2);
const basis = record.meta.basis;
const baseSay = record.approximate
? `Rough area around ${record.name}${size ? ` — ${size}` : ''}`
: `${record.name}${record.meta.country ? `, ${record.meta.country}` : ''}${size ? ` — ${size}` : ''}`;
const notDrawn = boardCleared
? 'the map was cleared while looking it up'
: drawing && !drawing.drawn
? drawing.reason
: null;
const drawFailed = Boolean(notDrawn);
return {
ok: true,
action: 'resolve_area',
...summarizeArea(record),
...(record.meta.adminName
? { sourceIdentity: record.meta.adminName }
: {}),
confidence: result.confidence,
cached: Boolean(result.cached),
ms: Date.now() - started,
resolveMs: result.ms ?? null,
...(drawing ? { drawn: drawing.drawn } : {}),
...(drawFailed
? {
partial: true,
notDrawn,
}
: {}),
...(boardCleared ? { drawn: false } : {}),
say: drawFailed
? `${baseSay}. I resolved the area, but couldn't draw the outline: ${notDrawn}.`
: baseSay,
display: {
source: SOURCE_LABELS[record.source] || record.source,
...(basis ? { basis } : {}),
...(record.parts > 1 ? { parts: `${record.parts} parts` } : {}),
...(drawing?.totalParts
? {
drawnParts: `${drawing.drawnParts} of ${drawing.totalParts} parts drawn`,
}
: {}),
},
};
}
return {
store,
resolver,
resolveAreaAction,
resolveAreaScope,
resolveRegion,
drawArea,
dispose: () => resolver.dispose(),
};
}
+877
View File
@@ -0,0 +1,877 @@
// resolve_area and the analyst area scopes as the voice runner sees them:
// what reaches the annotation engine, what reaches the model, and which
// outline a "drawn" or "outlined" scope counts over.
//
// Run with: npm test
import test from 'node:test';
import assert from 'node:assert/strict';
import { createVoiceAreas, spokenArea } from './areaActions.js';
import { pointInPreparedArea } from '../data/areaGeometry.js';
import { presentResult } from './resultDisplay.js';
import { findAdminCandidates as findBundledAdminCandidates } from '../data/adminBoundaries.js';
const square = (w, s, e, n) => [
[w, s],
[e, s],
[e, n],
[w, n],
[w, s],
];
const BAGMATI_POLYGONS = [
[square(84.4, 26.9, 86.6, 28.4), square(85.3, 27.6, 85.4, 27.7)],
];
/**
* The annotation outline ladder as the area actions see it: an anchor, then
* a deferred outline (here Bagmati from the guarded Nominatim route).
*/
function fakeLadder({
outline,
calls = [],
delay = null,
label = 'Bagmati Province',
match = /bagmati/i,
} = {}) {
return {
calls,
async resolveAnnotationTarget(request) {
calls.push(request);
if (!match.test(request.target)) return null;
return {
lat: 27.7,
lon: 85.3,
label,
ring: null,
resolveOutline: async () => {
if (delay) await delay;
if (outline !== undefined) return outline;
return {
ring: BAGMATI_POLYGONS[0][0],
polygons: BAGMATI_POLYGONS,
adminArea: 'Bagmati Province',
adminLevel: 'admin1',
synthesized: false,
outlineSource: 'nominatim',
};
},
};
},
};
}
const PUNJABS = [
{
kind: 'state',
name: 'Punjab',
country: 'India',
source: 'natural-earth',
polygons: [[square(73.8, 29.5, 77, 32.5)]],
},
{
kind: 'state',
name: 'Punjab',
country: 'Pakistan',
source: 'natural-earth',
polygons: [[square(69.3, 27.7, 73.7, 34)]],
},
];
function fakeAnnotations() {
const marks = [];
let seq = 0;
return {
marks,
calls: [],
async annotate(specs) {
this.calls.push(specs);
const results = specs.map((spec) => {
const mark = {
id: `anno-${(seq += 1)}`,
ring: spec.ring,
label: spec.label,
areaId: spec.areaId || null,
origin: spec.manual
? spec.origin === 'area'
? 'area'
: 'drawn'
: 'voice',
synthesized: spec.approximate === true,
createdAt: seq,
};
marks.push(mark);
return { ok: true, id: mark.id };
});
return { drawn: results.length, results };
},
list() {
return marks;
},
};
}
function makeAreas(overrides = {}) {
const annotations = overrides.annotations || fakeAnnotations();
const areas = createVoiceAreas({
viewer: {},
annotations,
annotationResolver: fakeLadder(),
findRegion: async () => null,
findAdminCandidates: async (q) => (/^punjab$/i.test(q) ? PUNJABS : []),
...overrides,
});
return { areas, annotations };
}
test('resolve_area outlines every part and hands the model a handle, not coordinates', async () => {
const { areas, annotations } = makeAreas();
const result = await areas.resolveAreaAction({
query: 'Bagmati province',
draw: true,
});
assert.equal(result.ok, true);
assert.equal(result.level, 'admin1');
assert.equal(result.source, 'osm');
assert.equal(result.sourceIdentity, 'Bagmati Province');
assert.equal(areas.store.get(result.areaId).meta.adminLevel, 'admin1');
assert.equal(result.display.source, 'OpenStreetMap');
assert.equal(result.holes, 1);
assert.equal(result.drawn, true);
assert.match(result.say, /^Bagmati Province — about [\d,]+ km²$/);
assert.equal(JSON.stringify(result).includes('coordinates'), false);
const [spec] = annotations.calls[0];
assert.equal(spec.manual, true);
assert.equal(spec.origin, 'area');
assert.equal(spec.areaId, result.areaId);
assert.equal(spec.polygons[0].length, 2, 'the hole is drawn too');
});
test('draw failures stay honest in the result, spoken line, and card', async (t) => {
const cases = [
{
name: 'annotation cap',
annotations: {
annotate: async () => ({
ok: false,
capped: true,
results: [{ ok: false, error: 'annotation limit reached' }],
}),
list: () => [],
},
reason: /annotation limit reached/,
},
{
name: 'renderer failure',
annotations: {
annotate: async () => ({
ok: false,
results: [{ ok: false, error: 'renderer unavailable' }],
}),
list: () => [],
},
reason: /renderer unavailable/,
},
{
name: 'missing annotation engine',
annotations: null,
reason: /unavailable/,
},
];
for (const entry of cases) {
await t.test(entry.name, async () => {
const areas = createVoiceAreas({
viewer: {},
annotations: entry.annotations,
annotationResolver: fakeLadder(),
findRegion: async () => null,
findAdminCandidates: async () => [],
});
const result = await areas.resolveAreaAction({
query: 'Bagmati province',
draw: true,
});
assert.equal(result.ok, true, 'the area itself resolved');
assert.equal(result.partial, true);
assert.equal(result.drawn, false);
assert.match(result.notDrawn, entry.reason);
assert.match(result.say, /resolved the area, but couldn't draw/i);
assert.match(result.say, entry.reason);
const card = presentResult('resolve_area', result, {
query: 'Bagmati province',
});
assert.ok(card.display.chips.some((chip) => chip.label === 'partial'));
assert.ok(card.display.lines.some((line) => /Not drawn:/i.test(line)));
});
}
});
test('a newer turn cancels area drawing before any annotation or flight commits', async () => {
let release;
let entered;
const enteredDraw = new Promise((resolve) => {
entered = resolve;
});
let committed = false;
const annotations = {
generation: () => 0,
async annotate(_specs, options) {
entered();
await new Promise((resolve) => {
release = resolve;
});
if (options.signal?.aborted || !options.isCurrent?.())
return { aborted: true, drawn: 0, results: [] };
committed = true;
return { drawn: 1, results: [{ ok: true, id: 'late-area' }] };
},
list: () => [],
};
const { areas } = makeAreas({ annotations });
const controller = new AbortController();
const pending = areas.resolveAreaAction(
{ query: 'Bagmati province', draw: true },
{
signal: controller.signal,
isCurrent: () => !controller.signal.aborted,
},
);
await enteredDraw;
controller.abort();
release();
const result = await pending;
assert.equal(result.code, 'CANCELLED');
assert.equal(result.cancelled, true);
assert.equal(committed, false);
});
test('an outline counts over the stored geometry, not the drawn copy', async () => {
const { areas } = makeAreas();
const resolved = await areas.resolveAreaAction({
query: 'Bagmati',
draw: true,
});
const scope = await areas.resolveAreaScope({ kind: 'annotation' });
assert.equal(scope.ok, true);
assert.equal(
scope.area.areaId,
resolved.areaId,
'the outline resolves back to its area',
);
assert.equal(
pointInPreparedArea(scope.area.prepared, 27.65, 85.35),
false,
'inside the hole',
);
assert.equal(pointInPreparedArea(scope.area.prepared, 27.0, 85.0), true);
});
test('"the area I drew" is the newest hand-drawn outline, never a voice outline', async () => {
const { areas, annotations } = makeAreas();
const none = await areas.resolveAreaScope({ kind: 'drawn' });
assert.equal(none.ok, false);
assert.equal(none.code, 'NO_DRAWN_AREA');
await annotations.annotate([
{ type: 'area', manual: true, ring: square(0, 0, 1, 1), label: null },
]);
await areas.resolveAreaAction({ query: 'Bagmati', draw: true });
const drawn = await areas.resolveAreaScope({ kind: 'drawn' });
assert.equal(drawn.ok, true);
assert.equal(drawn.area.name, 'the drawn area');
assert.equal(pointInPreparedArea(drawn.area.prepared, 0.5, 0.5), true);
// By id, and an unknown id refuses.
const byId = await areas.resolveAreaScope({
kind: 'annotation',
id: 'anno-1',
});
assert.equal(byId.area.name, 'the drawn area');
const unknown = await areas.resolveAreaScope({
kind: 'annotation',
id: 'anno-99',
});
assert.equal(unknown.ok, false);
const expired = await areas.resolveAreaScope({
kind: 'area',
areaId: 'area-99',
});
assert.equal(expired.code, 'AREA_UNKNOWN');
});
test('an ambiguous name returns candidates for the model to ask about', async () => {
const { areas } = makeAreas();
const result = await areas.resolveAreaAction({ query: 'Punjab', draw: true });
assert.equal(result.ok, false);
assert.equal(result.needsClarification, true);
assert.deepEqual(
result.candidates.map((c) => `${c.name}/${c.country}`),
['Punjab/India', 'Punjab/Pakistan'],
);
const picked = await areas.resolveAreaAction({
candidateId: result.candidates[1].candidateId,
});
assert.equal(picked.ok, true);
assert.equal(picked.country, 'Pakistan');
assert.equal(picked.display.source, 'Natural Earth');
});
test('names reach the annotation outline ladder with the level as entity kind', async () => {
const ladder = fakeLadder();
const { areas } = makeAreas({ annotationResolver: ladder });
await areas.resolveAreaAction({ query: 'Bagmati province' });
const [request] = ladder.calls;
assert.equal(request.target, 'Bagmati province');
assert.equal(request.entityKind, 'state');
assert.equal(request.footprint, true);
assert.equal(request.allowDistant, true);
});
test('a verified province accepts a bare query with a separate level', async () => {
const { areas } = makeAreas({ annotationResolver: fakeLadder() });
const result = await areas.resolveAreaAction({
query: 'Bagmati',
level: 'admin1',
});
assert.equal(result.ok, true);
assert.equal(result.name, 'Bagmati Province');
assert.equal(result.level, 'admin1');
assert.equal(result.sourceIdentity, 'Bagmati Province');
const unverified = makeAreas({
annotationResolver: fakeLadder({
outline: {
ring: BAGMATI_POLYGONS[0][0],
polygons: BAGMATI_POLYGONS,
adminArea: 'Bagmati',
synthesized: false,
outlineSource: 'nominatim',
},
}),
});
const refused = await unverified.areas.resolveAreaAction({
query: 'Bagmati',
level: 'admin1',
});
assert.equal(refused.ok, false);
assert.equal(refused.code, 'AREA_IDENTITY_UNVERIFIED');
assert.equal(unverified.areas.store.size, 0);
const implicit = makeAreas({
annotationResolver: fakeLadder({
outline: {
ring: BAGMATI_POLYGONS[0][0],
polygons: BAGMATI_POLYGONS,
adminArea: 'Bagmati',
synthesized: false,
outlineSource: 'nominatim',
},
}),
});
const implicitRefusal = await implicit.areas.resolveAreaAction({
query: 'Bagmati',
});
assert.equal(implicitRefusal.ok, false);
assert.equal(implicitRefusal.code, 'AREA_IDENTITY_UNVERIFIED');
assert.equal(implicit.areas.store.size, 0);
const retired = makeAreas({
annotationResolver: fakeLadder({
label: 'Bagmati',
outline: {
ring: BAGMATI_POLYGONS[0][0],
polygons: BAGMATI_POLYGONS,
adminArea: 'Bagmati Zone',
synthesized: false,
outlineSource: 'nominatim',
},
}),
});
const retiredRefusal = await retired.areas.resolveAreaAction({
query: 'Bagmati',
});
assert.equal(retiredRefusal.ok, false);
assert.equal(retiredRefusal.code, 'AREA_IDENTITY_MISMATCH');
assert.equal(retired.areas.store.size, 0);
});
test('an explicit level accepts the names the administrative matcher reads as that unit', async () => {
const bundled = () =>
makeAreas({ findAdminCandidates: findBundledAdminCandidates }).areas;
for (const [query, level, name] of [
['State of Texas', 'admin1', 'Texas'],
['State of Texas', undefined, 'Texas'],
['USA', 'country', 'United States of America'],
['UK', 'country', 'United Kingdom'],
]) {
const result = await bundled().resolveAreaAction({ query, level });
assert.equal(result.ok, true, `${query} (${level}): ${result.error}`);
assert.equal(result.name, name);
assert.equal(result.level, level || 'admin1');
}
// The level still has to match the unit.
for (const [query, level] of [
['State of Texas', 'country'],
['USA', 'admin1'],
]) {
const areas = bundled();
const result = await areas.resolveAreaAction({ query, level });
assert.equal(result.ok, false, `${query} is not a ${level}`);
assert.equal(areas.store.size, 0);
}
// The outline ladder's identity check reads the same forms.
const prefixed = makeAreas().areas;
const bagmati = await prefixed.resolveAreaAction({
query: 'Province of Bagmati',
level: 'admin1',
});
assert.equal(bagmati.ok, true, bagmati.error);
assert.equal(bagmati.name, 'Bagmati Province');
const wrongLevel = makeAreas().areas;
const county = await wrongLevel.resolveAreaAction({
query: 'Province of Bagmati',
level: 'admin2',
});
assert.equal(county.ok, false);
assert.equal(county.code, 'AREA_IDENTITY_MISMATCH');
assert.equal(wrongLevel.store.size, 0);
});
test('a current Zone the source verified is not refused as retired', async () => {
const gamo = makeAreas({
annotationResolver: fakeLadder({
label: 'Gamo Zone',
match: /gamo/i,
outline: {
ring: BAGMATI_POLYGONS[0][0],
polygons: BAGMATI_POLYGONS,
adminArea: 'Gamo Zone',
adminLevel: 'admin2',
synthesized: false,
outlineSource: 'nominatim',
},
}),
});
const result = await gamo.areas.resolveAreaAction({ query: 'Gamo Zone' });
assert.equal(result.ok, true, result.error);
assert.equal(gamo.areas.store.size, 1);
});
test('an outline named with accents is accepted for the same name spoken without them', async () => {
const quebec = makeAreas({
annotationResolver: fakeLadder({
label: 'Québec',
match: /quebec/i,
outline: {
ring: BAGMATI_POLYGONS[0][0],
polygons: BAGMATI_POLYGONS,
adminArea: 'Québec',
adminLevel: 'admin1',
synthesized: false,
outlineSource: 'nominatim',
},
}),
});
const result = await quebec.areas.resolveAreaAction({
query: 'Quebec province',
});
assert.equal(result.ok, true, result.error);
});
test('a place no outline source can trace is refused, not guessed', async () => {
const miss = makeAreas({
annotationResolver: fakeLadder({ outline: null }),
});
const none = await miss.areas.resolveAreaAction({ query: 'Bagmati' });
assert.equal(none.code, 'AREA_NOT_FOUND');
const off = makeAreas({
annotationResolver: fakeLadder({
outline: { unavailable: true, retryable: false },
}),
});
const refused = await off.areas.resolveAreaAction({ query: 'Bagmati' });
assert.equal(refused.code, 'AREA_UNAVAILABLE');
assert.match(refused.error, /stays a point/);
const unknown = await off.areas.resolveAreaAction({ query: 'Atlantis' });
assert.equal(unknown.code, 'AREA_NOT_FOUND');
});
test('an old or unverified administrative outline is refused and never stored', async () => {
for (const [outline, code] of [
[
{
ring: BAGMATI_POLYGONS[0][0],
polygons: BAGMATI_POLYGONS,
adminArea: 'Bagmati Zone',
outlineSource: 'nominatim',
},
'AREA_IDENTITY_MISMATCH',
],
[
{
ring: BAGMATI_POLYGONS[0][0],
polygons: BAGMATI_POLYGONS,
adminArea: 'Bagmati Province',
adminLevel: 'admin2',
outlineSource: 'nominatim',
},
'AREA_IDENTITY_MISMATCH',
],
[
{
ring: BAGMATI_POLYGONS[0][0],
polygons: BAGMATI_POLYGONS,
adminArea: 'Bagmati Province',
outlineSource: 'nominatim',
},
'AREA_IDENTITY_UNVERIFIED',
],
[
{
ring: BAGMATI_POLYGONS[0][0],
polygons: BAGMATI_POLYGONS,
outlineSource: 'nominatim',
},
'AREA_IDENTITY_UNVERIFIED',
],
[
{
ring: BAGMATI_POLYGONS[0][0],
polygons: BAGMATI_POLYGONS,
synthesized: true,
outlineSource: 'nominatim',
},
'AREA_IDENTITY_UNVERIFIED',
],
]) {
const { areas } = makeAreas({
annotationResolver: fakeLadder({ outline }),
});
const result = await areas.resolveAreaAction({
query: 'Bagmati province',
draw: true,
});
assert.equal(result.ok, false);
assert.equal(result.code, code);
assert.equal(areas.store.size, 0, 'a refused outline is not reusable');
const repeated = await areas.resolveAreaAction({
query: 'Bagmati province',
});
assert.equal(repeated.code, code);
assert.equal(repeated.areaId, undefined);
assert.equal(areas.store.size, 0);
}
});
test('a region scope reuses the ladder and asks on ambiguity', async () => {
const { areas } = makeAreas();
const region = await areas.resolveRegion('Bagmati');
assert.equal(region.name, 'Bagmati Province');
assert.equal(region.sourceLabel, 'OpenStreetMap');
assert.ok(region.prepared);
const asked = await areas.resolveRegion('Punjab');
assert.equal(asked.needsClarification, true);
assert.equal(await areas.resolveRegion('Atlantis'), null);
});
test('an area around a landmark uses its enclosing outline, else a labelled buffer', async () => {
const around = (outline, extra = {}) => ({
resolveAnnotationTarget: async (request) => {
assert.equal(request.entityKind, 'compound', 'grounds, not the building');
return {
lat: 37.7955,
lon: -122.3937,
label: 'Ferry Building',
ring: null,
...(outline === undefined
? {}
: { resolveOutline: async () => outline }),
...extra,
};
},
});
const grounds = makeAreas({
annotationResolver: around({
ring: square(-122.395, 37.794, -122.392, 37.797),
polygons: [[square(-122.395, 37.794, -122.392, 37.797)]],
synthesized: false,
outlineSource: 'openfreemap',
}),
});
const real = await grounds.areas.resolveAreaAction({
query: 'Ferry Building, San Francisco',
around: true,
});
assert.equal(real.ok, true);
assert.equal(real.source, 'openfreemap');
assert.equal(real.approximate, undefined);
assert.equal(real.drawn, true, 'an around-area is always drawn');
assert.equal(real.display.basis, 'enclosing mapped area');
assert.equal(real.name, 'Ferry Building');
// No enclosing outline (Nominatim off, nothing in the tiles): a buffer.
const bare = makeAreas({
annotationResolver: around({ unavailable: true, retryable: false }),
});
const approx = await bare.areas.resolveAreaAction({
query: 'Ferry Building',
around: true,
});
assert.equal(approx.ok, true);
assert.equal(approx.approximate, true);
assert.equal(approx.source, 'approximate');
assert.match(approx.say, /^Rough area around Ferry Building/);
assert.equal(approx.display.basis, '250 m around the landmark');
assert.equal(bare.annotations.calls[0][0].approximate, true, 'drawn dashed');
assert.ok(approx.areaKm2 > 0.15 && approx.areaKm2 < 0.25);
// A buffer the ladder sized itself is still a buffer.
const sized = makeAreas({
annotationResolver: around({
ring: square(-122.395, 37.794, -122.392, 37.797),
synthesized: true,
}),
});
const disc = await sized.areas.resolveAreaAction({
query: 'Ferry Building',
around: true,
});
assert.equal(disc.approximate, true);
// A slow outline gives way to the buffer instead of holding the answer.
const slow = makeAreas({
aroundOutlineMs: 20,
annotationResolver: {
resolveAnnotationTarget: async () => ({
lat: 37.7955,
lon: -122.3937,
ring: null,
resolveOutline: () => new Promise(() => {}),
}),
},
});
const started = Date.now();
const buffered = await slow.areas.resolveAreaAction({
query: 'Ferry Building',
around: true,
});
assert.ok(Date.now() - started < 1000);
assert.equal(buffered.approximate, true);
assert.equal(buffered.display.basis, '250 m around the landmark');
// A radius the user gave never needs the outline, so it is never looked up.
let outlineLookups = 0;
const radius = makeAreas({
annotationResolver: {
resolveAnnotationTarget: async () => ({
lat: 37.7955,
lon: -122.3937,
ring: null,
resolveOutline: async () => {
outlineLookups += 1;
return null;
},
}),
},
});
const sizedBuffer = await radius.areas.resolveAreaAction({
query: 'Ferry Building',
around: true,
radiusM: 400,
});
assert.equal(sizedBuffer.display.basis, '400 m around the landmark');
assert.equal(outlineLookups, 0, 'no outline request for a sized buffer');
const missing = makeAreas({
annotationResolver: { resolveAnnotationTarget: async () => null },
});
const lost = await missing.areas.resolveAreaAction({
query: 'Nowhere Hall',
around: true,
});
assert.equal(lost.code, 'AREA_NOT_FOUND');
});
test('a superseded resolve returns cancelled and draws nothing', async () => {
const { areas, annotations } = makeAreas();
let current = true;
const pending = areas.resolveAreaAction(
{ query: 'Bagmati', draw: true },
{ isCurrent: () => current },
);
current = false;
const result = await pending;
assert.equal(result.code, 'CANCELLED');
assert.equal(annotations.calls.length, 0);
});
test('spoken areas round to two significant figures', () => {
assert.equal(spokenArea(20_312.4), 'about 20,000 km²');
assert.equal(spokenArea(0.196), 'about 0.2 km²');
assert.equal(spokenArea(1234), 'about 1,200 km²');
assert.equal(spokenArea(0), null);
});
// ── Review regressions: board ownership, eviction, runner replacement ──
/** Annotations fake with the engine's board generation and clear(). */
function boardAnnotations() {
const fake = fakeAnnotations();
let generation = 0;
fake.generation = () => generation;
fake.clear = () => {
generation += 1;
fake.marks.length = 0;
};
return fake;
}
test('a clear during a slow lookup is not undone by a late outline', async () => {
let release;
const annotations = boardAnnotations();
const gate = new Promise((resolve) => {
release = resolve;
});
const areas = createVoiceAreas({
viewer: {},
annotations,
findRegion: async () => null,
findAdminCandidates: async () => [],
annotationResolver: fakeLadder({ delay: gate }),
});
const pending = areas.resolveAreaAction({ query: 'Bagmati', draw: true });
await new Promise((resolve) => setTimeout(resolve, 5));
annotations.clear(); // the person clears the map while the lookup runs
release();
const result = await pending;
assert.equal(result.ok, true, 'the area itself still resolves');
assert.equal(result.drawn, false);
assert.match(result.notDrawn, /cleared/);
assert.equal(result.partial, true);
assert.match(result.say, /couldn't draw the outline:.*cleared/i);
assert.equal(annotations.calls.length, 0, 'nothing drawn, no camera flight');
// Asked again after the clear, it draws.
const again = await areas.resolveAreaAction({ query: 'Bagmati', draw: true });
assert.equal(again.drawn, true);
});
test('an outlined area keeps its full geometry past store eviction', async () => {
const { areas } = makeAreas();
const resolved = await areas.resolveAreaAction({
query: 'Bagmati',
draw: true,
});
// Far more areas than the store holds, none of them on the map.
for (let i = 0; i < 40; i += 1)
areas.store.put({
geometry: [[square(i, 0, i + 0.5, 0.5)]],
name: `filler ${i}`,
source: 'osm',
});
const scope = await areas.resolveAreaScope({ kind: 'annotation' });
assert.equal(scope.ok, true);
assert.equal(scope.area.areaId, resolved.areaId);
assert.equal(
pointInPreparedArea(scope.area.prepared, 27.65, 85.35),
false,
'the hole is still a hole',
);
assert.equal(areas.store.has(resolved.areaId), true);
});
test('an outline from before a page reload asks to resolve again, never counts its drawing', async () => {
const annotations = fakeAnnotations();
const first = createVoiceAreas({
viewer: {},
annotations,
annotationResolver: fakeLadder(),
findRegion: async () => null,
findAdminCandidates: async () => [],
});
await first.resolveAreaAction({ query: 'Bagmati', draw: true });
// A new runner means a reloaded page: the store is new while the persisted
// outline stays on the map. A mic restart keeps the runner (and handles);
// see integrationContract.test.mjs.
const second = createVoiceAreas({
viewer: {},
annotations,
findRegion: async () => null,
findAdminCandidates: async () => [],
});
const scope = await second.resolveAreaScope({ kind: 'annotation' });
assert.equal(scope.ok, false);
assert.equal(scope.code, 'AREA_EXPIRED');
});
test('a voice outline without an area handle counts over every part it drew', async () => {
const { areas, annotations } = makeAreas();
annotations.marks.push({
id: 'anno-9',
ring: square(0, 0, 1, 1),
polygons: [[square(0, 0, 1, 1)], [square(2, 2, 3, 3)]],
label: 'Presidio',
origin: 'voice',
createdAt: 99,
});
const scope = await areas.resolveAreaScope({ kind: 'annotation' });
assert.equal(scope.ok, true);
assert.equal(scope.area.approximate, false);
assert.equal(pointInPreparedArea(scope.area.prepared, 2.5, 2.5), true);
annotations.marks.push({
id: 'anno-10',
ring: square(5, 5, 6, 6),
label: 'Around the pier',
origin: 'voice',
synthesized: true,
createdAt: 100,
});
const buffer = await areas.resolveAreaScope({ kind: 'annotation' });
assert.equal(buffer.area.approximate, true, 'a buffer says approximate');
});
test('an area around a landmark keeps the enclosing feature’s parts and holes', async () => {
const { areas, annotations } = makeAreas({
annotationResolver: {
resolveAnnotationTarget: async () => ({
lat: 37.7955,
lon: -122.3937,
ring: null,
resolveOutline: async () => ({
ring: square(-122.395, 37.794, -122.392, 37.797),
polygons: [
[
square(-122.395, 37.794, -122.392, 37.797),
square(-122.394, 37.795, -122.393, 37.796),
],
[square(-122.39, 37.79, -122.389, 37.791)],
],
adminArea: 'Pier Complex',
synthesized: false,
outlineSource: 'nominatim',
}),
}),
},
});
const result = await areas.resolveAreaAction({
query: 'Pier Complex',
around: true,
});
assert.equal(result.source, 'osm');
assert.equal(result.parts, 2);
assert.equal(result.holes, 1);
assert.equal(result.approximate, undefined);
assert.equal(annotations.calls[0][0].polygons.length, 2, 'both parts drawn');
const scope = await areas.resolveAreaScope({
kind: 'area',
areaId: result.areaId,
});
assert.equal(
pointInPreparedArea(scope.area.prepared, 37.7955, -122.3935),
false,
'the courtyard hole',
);
assert.equal(
pointInPreparedArea(scope.area.prepared, 37.7905, -122.3895),
true,
'the second part',
);
});
+20
View File
@@ -0,0 +1,20 @@
/** Shared producer/consumer contract for `resolve_area.candidateId`. */
/** Room for every currently emitted bundled candidate (observed maximum: 45). */
export const MAX_AREA_CANDIDATE_ID_LENGTH = 80;
/** JSON Schema-compatible grammar for bundled candidate handles. */
export const AREA_CANDIDATE_ID_PATTERN =
'^ne:(?:country|state|county|natural):[\\p{L}\\p{M}\\p{N}]+(?:-[\\p{L}\\p{M}\\p{N}]+)*$';
const AREA_CANDIDATE_ID_RE =
/^ne:(?:country|state|county|natural):[\p{L}\p{M}\p{N}]+(?:-[\p{L}\p{M}\p{N}]+)*$/u;
export function isAreaCandidateId(value) {
const candidateId = String(value || '');
return (
candidateId.length > 0 &&
candidateId.length <= MAX_AREA_CANDIDATE_ID_LENGTH &&
AREA_CANDIDATE_ID_RE.test(candidateId)
);
}
+16
View File
@@ -9,6 +9,7 @@
* get_entity_context {scope:'pointer'} | {referent:n}
* analyst_query {scope:{kind:'pointer', km?}}
* annotate_map annotations[].target / toTarget / points[].target = 'pointer'
* find_imagery {area:'pointer'}
*
* The pointer is the current turn's snapshot (see pointerContext.js); the
* referents are the latest result's numbered list (see referents.js).
@@ -266,6 +267,20 @@ function resolveAnnotate(args, { pointer }) {
};
}
function resolveImagery(args, { pointer }) {
if (!isPointer(args.area)) return null;
const target = pointerTarget(pointer);
if (!target || !hasPosition(target)) return { error: NO_POINTER };
return {
args: {
...withoutKeys(args, ['area']),
center: { lat: target.lat, lon: target.lon },
...(target.label ? { centerLabel: target.label } : {}),
},
used: { source: 'pointer', label: target.label || null },
};
}
const SCREEN_PAIRS = [
['screenX', 'screenY'],
['toScreenX', 'toScreenY'],
@@ -333,6 +348,7 @@ const RESOLVERS = Object.freeze({
get_entity_context: resolveContext,
analyst_query: resolveAnalyst,
annotate_map: resolveAnnotate,
find_imagery: resolveImagery,
});
/**
+226 -44
View File
@@ -60,6 +60,14 @@ import {
activeContactsWindow,
TRACKABLE_FAMILIES,
} from '../agent/worldPacket.js';
import { createVoiceAreas } from './areaActions.js';
import { findImagery } from './imageryActions.js';
import { osmQuery } from './osmActions.js';
import { createOsmFeatureSource } from '../sources/osmFeatures.js';
import {
OSM_PLACES_CREDIT,
registerDynamicCredit,
} from '../data/dataCredits.js';
const ALLOWED_STYLES = new Set([
'normal',
@@ -268,6 +276,8 @@ export function createGevActionRunner({
speechBuilders = null,
deixis = null,
readVoicePanelOperation = null,
osmSearch = null,
areaOptions = null,
}) {
// Voice enable times and analyst follow-up memory belong to this runner.
// Speech builders turn results into say/display envelopes; callers extend
@@ -277,13 +287,25 @@ export function createGevActionRunner({
: SPEECH_BUILDERS;
const _layerEnabledAt = new Map();
let analystEngine;
const resolveRegionRing = (name, signal) =>
annotationResolver.resolveRegionRingForQuery(name, signal, placeSearch);
// Area handles live for this runner (the page): resolve_area, the
// analyst's region/area/drawn scopes, find_imagery and osm_query all read
// the same store.
const voiceAreas = createVoiceAreas({
viewer,
annotations,
placeSearch,
annotationResolver,
...(areaOptions || {}),
});
const searchOsmFeatures = osmSearch || createOsmFeatureSource();
const resolveRegionRing = (name, options) =>
voiceAreas.resolveRegion(name, options);
const ensureAnalystEngine = () =>
(analystEngine ||= createAnalystEngine(
analystProviders(viewer, dataManager, {
placeSearch,
resolveRegionRing,
resolveAreaScope: voiceAreas.resolveAreaScope,
isWarming: (layerKey) => layerIsWarming(_layerEnabledAt, layerKey),
}),
));
@@ -1161,6 +1183,65 @@ export function createGevActionRunner({
return nextIssPass(viewer, dataManager, args);
}
if (name === 'resolve_area') {
return voiceAreas.resolveAreaAction(args, {
signal: runOptions.signal,
isCurrent: current,
progress: reportProgress,
});
}
if (name === 'find_imagery') {
return findImagery(
{
dataManager,
viewer,
getArea: (areaId) => voiceAreas.store.get(areaId),
enableLayer: (enabled) =>
runGevAction(
'set_layer_visibility',
{ layerId: 'recent-imagery', enabled },
runOptions,
),
progress: reportProgress,
},
args,
{ isCurrent: current, signal: runOptions.signal },
);
}
if (name === 'osm_query') {
return osmQuery(
{
dataManager,
getArea: (areaId) => voiceAreas.store.get(areaId),
viewBox: () => cameraViewBox(viewer),
enableLayer: (enabled) =>
runGevAction(
'set_layer_visibility',
{ layerId: 'osm-places', enabled },
runOptions,
),
search: searchOsmFeatures,
onCredit: () => registerDynamicCredit(viewer, OSM_PLACES_CREDIT),
// The listed places become the analyst's "last answer", so
// "which of those…" filters them and never an older set.
rememberResults: (rows, presentation, { truncated = false } = {}) => {
if (!current()) return;
if (rows)
ensureAnalystEngine().remember('osm-places', rows, {
presentation,
truncated,
});
else analystEngine?.reset();
},
progress: reportProgress,
},
args,
{ isCurrent: current, signal: runOptions.signal },
);
}
if (name === 'analyst_query') {
return runAnalystQuery(
ensureAnalystEngine(),
@@ -1352,10 +1433,31 @@ export function createGevActionRunner({
throw new Error(`Unknown GEV tool: ${name}`);
};
// Lifetimes: the runner lives for the page; conversation state — the
// Lifetimes: the runner lives for the page (area handles included, so an
// outline stays countable across mic sessions); conversation state — the
// analyst's follow-up memory and any write still in flight — lives for one
// voice session and is dropped by resetConversation().
let conversation = 0;
// Calls emitted by one assistant response are siblings rather than newer
// user intent. The new spatial actions share mutable surfaces (area board,
// imagery panel, OSM layer/follow-up memory), so serialize those siblings in
// event order just as analyst_query already was. A genuinely newer group or
// direct call still invalidates the older attempt.
const serializedSiblingActions = new Set([
'analyst_query',
'resolve_area',
'find_imagery',
'osm_query',
]);
let serializedAttempt = 0;
let serializedSiblingGroup = null;
let serializedSiblingTail = Promise.resolve();
// Direct/programmatic callers do not necessarily arrive through
// createVoiceSession, so the runner must own the conversation lifetime of
// its serialized spatial workflows as well. Reset/dispose abort the real
// provider/catalog work, not just the eventual commit, while area handles
// remain owned by voiceAreas for the page lifetime.
let conversationAbort = new AbortController();
let disposed = false;
async function runVoiceAction(name, rawArgs = {}, callerOptions = {}) {
if (disposed)
@@ -1366,56 +1468,113 @@ export function createGevActionRunner({
error: 'Voice ended.',
};
const epoch = conversation;
let attempt = null;
let releaseSerializedSibling = null;
if (serializedSiblingActions.has(name)) {
const siblingGroup = callerOptions.authorityGroup || null;
if (siblingGroup) {
if (siblingGroup !== serializedSiblingGroup) {
serializedSiblingGroup = siblingGroup;
serializedSiblingTail = Promise.resolve();
serializedAttempt++;
}
attempt = serializedAttempt;
const predecessor = serializedSiblingTail;
const completion = new Promise((resolve) => {
releaseSerializedSibling = resolve;
});
serializedSiblingTail = predecessor.then(() => completion);
await predecessor;
} else {
serializedSiblingGroup = null;
serializedSiblingTail = Promise.resolve();
attempt = ++serializedAttempt;
}
}
const runnerSignal = serializedSiblingActions.has(name)
? conversationAbort.signal
: null;
const signal = runnerSignal
? callerOptions.signal
? AbortSignal.any([runnerSignal, callerOptions.signal])
: runnerSignal
: callerOptions.signal;
const runOptions = {
...callerOptions,
...(signal ? { signal } : {}),
isCurrent: () =>
!signal?.aborted &&
epoch === conversation &&
(attempt === null || attempt === serializedAttempt) &&
!disposed &&
(typeof callerOptions.isCurrent !== 'function' ||
callerOptions.isCurrent()),
};
const input = rawArgs && typeof rawArgs === 'object' ? rawArgs : {};
// Point-and-ask: 'pointer' and referent:n become real targets here, from
// the turn's pointer snapshot and the latest numbered result.
const deictic = resolveDeicticArgs(name, input, {
pointer: deixis?.pointer?.activeSnapshot?.() || null,
referents: deixis?.referents || null,
imageFrame: deixis?.imageFrame?.() || null,
cameraKey: deixis?.cameraKey?.() || null,
});
if (deictic.error) {
return attachVoiceResult(
name,
{ ok: false, action: name, error: deictic.error },
input,
builders,
);
}
const referentGeneration = deixis?.referents?.generation?.();
let result = await runGevAction(name, deictic.args, runOptions);
if (deictic.used && result && typeof result === 'object')
result = { ...result, resolvedFrom: deictic.used };
const out = attachVoiceResult(name, result, deictic.args, builders);
// Only an accepted, still-current result may become "the last list": a
// cancelled turn or one finishing after the session ended must not
// repopulate a cleared registry.
const stillCurrent =
!runOptions.signal?.aborted &&
(typeof runOptions.isCurrent !== 'function' || runOptions.isCurrent());
if (stillCurrent)
deixis?.referents?.recordResult?.(name, out, {
since: referentGeneration,
try {
const input = rawArgs && typeof rawArgs === 'object' ? rawArgs : {};
if (attempt !== null && !runOptions.isCurrent()) {
return attachVoiceResult(
name,
{
ok: false,
action: name,
code: 'CANCELLED',
cancelled: true,
error: 'This request was superseded.',
},
input,
builders,
);
}
// Point-and-ask: 'pointer' and referent:n become real targets here, from
// the turn's pointer snapshot and the latest numbered result.
const deictic = resolveDeicticArgs(name, input, {
pointer: deixis?.pointer?.activeSnapshot?.() || null,
referents: deixis?.referents || null,
imageFrame: deixis?.imageFrame?.() || null,
cameraKey: deixis?.cameraKey?.() || null,
});
if (stillCurrent && deictic.used?.source === 'pointer')
deixis?.onPointerUsed?.();
return out;
if (deictic.error) {
return attachVoiceResult(
name,
{ ok: false, action: name, error: deictic.error },
input,
builders,
);
}
const referentGeneration = deixis?.referents?.generation?.();
let result = await runGevAction(name, deictic.args, runOptions);
if (deictic.used && result && typeof result === 'object')
result = { ...result, resolvedFrom: deictic.used };
const out = attachVoiceResult(name, result, deictic.args, builders);
// Only an accepted, still-current result may become "the last list": a
// cancelled turn or one finishing after the session ended must not
// repopulate a cleared registry.
const stillCurrent =
!runOptions.signal?.aborted &&
(typeof runOptions.isCurrent !== 'function' || runOptions.isCurrent());
if (stillCurrent)
deixis?.referents?.recordResult?.(name, out, {
since: referentGeneration,
});
if (stillCurrent && deictic.used?.source === 'pointer')
deixis?.onPointerUsed?.();
return out;
} finally {
releaseSerializedSibling?.();
}
}
/**
* A voice session ended: invalidate every write still in flight, then
* forget the analyst's "last answer".
* forget the analyst's "last answer". Area handles stay (page lifetime).
*/
runVoiceAction.resetConversation = () => {
conversationAbort.abort();
conversationAbort = new AbortController();
conversation++;
serializedAttempt++;
serializedSiblingGroup = null;
serializedSiblingTail = Promise.resolve();
analystEngine?.reset();
};
/** Voice was removed: nothing may run or write again. */
@@ -1423,6 +1582,7 @@ export function createGevActionRunner({
if (disposed) return;
runVoiceAction.resetConversation();
disposed = true;
voiceAreas.dispose();
};
return runVoiceAction;
}
@@ -3881,8 +4041,14 @@ async function aircraftProximityWindowForQuery(
layerKeys: wanted,
coverage,
unanswered: [...(result?.unanswered || [])],
scopeLabel: answer.scopeLabel,
scopeDetail: answer.display.scope,
presentation: {
scopeLabel: answer.scopeLabel,
display: answer.display,
coverageScope: coverage.scope,
coverageNote: coverage.note,
centeredOn: answer.window.centeredOn,
window: answer.window,
},
},
};
}
@@ -4771,6 +4937,7 @@ function analystProviders(
placeSearch,
),
isWarming = () => false,
resolveAreaScope = null,
} = {},
) {
// Per-layer truncation seen by the last getRecords call.
@@ -4825,6 +4992,7 @@ function analystProviders(
return note ? note.trim() : null;
},
resolveRegionRing,
...(resolveAreaScope ? { resolveAreaScope } : {}),
/**
* The active Contacts subject, when there is one — the centre the operator
* is reasoning about while Contacts is up. Null whenever Contacts is off,
@@ -4860,6 +5028,15 @@ function analystProviders(
};
}
/** The camera's view rectangle as [west, south, east, north] degrees, or null. */
function cameraViewBox(viewer) {
const rect = viewer?.camera?.computeViewRectangle?.();
if (!rect) return null;
return [rect.west, rect.south, rect.east, rect.north].map((radians) =>
Cesium.Math.toDegrees(radians),
);
}
/** Identity fields every compact analyst item keeps when present. */
const ANALYST_IDENTITY_FIELDS = Object.freeze([
'icao24',
@@ -4919,6 +5096,9 @@ const ANALYST_REFUSAL_FIELDS = Object.freeze([
'layers',
'layerStatus',
'cancelled',
'needsClarification',
'candidates',
'query',
]);
const VIEWPORT_COVERAGE_NOTE = 'the flights layer loads by viewport';
@@ -4967,7 +5147,7 @@ async function runAnalystQuery(
args = {},
_layerEnabledAt,
isCurrent = () => true,
signal = undefined,
signal = null,
) {
let entityWindow = null;
const result = await analystEngine.query(
@@ -4981,8 +5161,8 @@ async function runAnalystQuery(
followUp: Boolean(args.followUp),
},
{
isCurrent,
signal,
isCurrent,
beforeCommit: async (candidate) => {
const projected = await aircraftProximityWindowForQuery(
dataManager,
@@ -5011,8 +5191,8 @@ async function runAnalystQuery(
code: result.code || null,
error: result.error,
};
// The engine's semantic fields pass through unchanged: a cancellation
// stays a cancellation.
// The engine's semantic fields pass through unchanged: a clarification
// keeps its candidates, a cancellation stays a cancellation.
for (const key of ANALYST_REFUSAL_FIELDS)
if (result[key] !== undefined) refusal[key] = result[key];
if (result.coverage) refusal.coverage = result.coverage;
@@ -5085,7 +5265,9 @@ async function runAnalystQuery(
...(result.partial
? { partial: true, unanswered: result.unanswered || [] }
: {}),
...(result.areaId ? { areaId: result.areaId } : {}),
...(result.centeredOn ? { centeredOn: result.centeredOn } : {}),
...(result.window ? { window: result.window } : {}),
// Every count names its scope in words; a bare number is what made two
// honest answers look like a contradiction.
scopeLabel: result.scopeLabel,
File diff suppressed because it is too large. Load diff
+36
View File
@@ -899,6 +899,42 @@ test('Radio handoff waits for response.done so later multi-intent tools execute
]);
});
test('Realtime gives analyst siblings one authority group and a newer response a new group', async () => {
const groups = [];
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
runner: async (name, _args, options) => {
groups.push([name, options.authorityGroup]);
return { ok: true, action: name, count: 0, items: [] };
},
});
controller.debugLog = () => {};
controller.sendVisualContextIfUseful = async () => false;
controller.dc = { readyState: 'open', send() {}, close() {} };
const event = (payload) => ({ data: JSON.stringify(payload) });
for (const [responseId, callId] of [
['response-1', 'analyst-a'],
['response-1', 'analyst-b'],
['response-2', 'analyst-c'],
]) {
await controller.handleRealtimeEvent(
event({
type: 'response.function_call_arguments.done',
response_id: responseId,
call_id: callId,
name: 'analyst_query',
arguments: '{"layers":["flights"],"scope":{"kind":"anywhere"}}',
}),
);
}
assert.deepEqual(groups, [
['analyst_query', 'response-1'],
['analyst_query', 'response-1'],
['analyst_query', 'response-2'],
]);
});
test('Radio playback failure leaves voice connected and speaks a correction', async () => {
const sent = [];
const order = [];
+405
View File
@@ -0,0 +1,405 @@
/**
* `find_imagery`: search the Recent Imagery layer's own catalog for an area,
* pick the best acquisition and show it through the layer's own selection
* (the pinned day in IMAGE mode), so the panel, the map and the answer agree.
*
* The area is an area handle's bounding box, the current view, or a 10 km box
* around a point. "This spot" arrives as that point: the runner resolves the
* `'pointer'` sentinel through the turn's pointer snapshot (deixis.js), with
* the same freshness and session rules as every other pointing tool. The
* layer's box limits apply unchanged: a box it refuses is refused here with
* the layer's own reason.
*
* The panel is the operator's. Voice owns it only from its own box until its
* pin shows; any hand change in between (box, pins, sources or mode) ends the
* request without touching the panel again.
*
* @module voice/imageryActions
*/
import { PRODUCTS, rankLatest } from '../layers/recentImagery/model.js';
import {
displacedLayerResult,
watchExplicitLayerOff,
} from './layerActionOwnership.js';
const LAYER_ID = 'recent-imagery';
/** How long the catalog search may take before the answer says "still searching". */
const SEARCH_WAIT_MS = 20_000;
/** How long to wait for the chosen day's pixels before answering anyway. */
const DRAPE_WAIT_MS = 8_000;
const DAY_MS = 86_400_000;
const round = (value) => Math.round(value);
/** Identity of a selection box, for telling ours from the operator's. */
const boxKey = (box) =>
box
? ['west', 'south', 'east', 'north']
.map((edge) => Number(box[edge]).toFixed(6))
.join(',')
: '';
/**
* Identity of everything the operator can change on the panel: box, both
* pins, sources and mode. Voice records it after each of its own changes;
* any other difference means the operator took the panel back.
*/
export function panelOwnershipKey(snapshot) {
return JSON.stringify([
snapshot?.enabled ?? null,
boxKey(snapshot?.box),
snapshot?.pins?.a?.key ?? null,
snapshot?.pins?.b?.key ?? null,
snapshot?.mode ?? null,
snapshot?.sources ?? null,
]);
}
/**
* Parse `dateRange` into epoch-ms bounds. Accepts `{from, to}` ISO dates or
* `{days}` (the last N days).
* @returns {{from: number, to: number}|null|{error: string}}
*/
export function imageryDateRange(dateRange, now = Date.now()) {
if (!dateRange || typeof dateRange !== 'object') return null;
if (Number.isFinite(Number(dateRange.days)) && Number(dateRange.days) > 0)
return { from: now - Number(dateRange.days) * DAY_MS, to: now };
const from = dateRange.from ? Date.parse(dateRange.from) : -Infinity;
// A bare end date means the whole of that day.
const to = dateRange.to
? Date.parse(dateRange.to) +
(/^\d{4}-\d{2}-\d{2}$/.test(dateRange.to) ? DAY_MS - 1 : 0)
: now;
if (Number.isNaN(from) || Number.isNaN(to))
return { error: 'dateRange needs ISO dates (YYYY-MM-DD) or days.' };
return { from, to };
}
/**
* The candidates that satisfy the filters, ranked by the layer's own rule
* (coverage, then clear sky, then newest).
* @param {Array<object>} candidates Layer snapshot candidates.
* @param {{from?: number, to?: number, maxCloudPct?: number|null, truncated?: boolean}} filters
*/
export function selectImagery(
candidates,
{
from = -Infinity,
to = Infinity,
maxCloudPct = null,
truncated = false,
} = {},
) {
// Compared as UTC days: an acquisition belongs to the day it was taken.
const dayOf = (ms) =>
Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : null;
const first = dayOf(from);
const last = dayOf(to);
const inRange = (candidate) =>
(!first || candidate.day >= first) && (!last || candidate.day <= last);
const cloudOk = (candidate) =>
maxCloudPct === null ||
PRODUCTS[candidate.product]?.overview ||
(candidate.cloud && candidate.cloud.max <= maxCloudPct);
const eligible = (candidates || []).filter(
(c) => c.availability !== 'empty' && inRange(c) && cloudOk(c),
);
const ranked = rankLatest(eligible, {
maxCloud: maxCloudPct ?? undefined,
truncated,
});
return { eligible, best: ranked.candidate, reason: ranked.reason };
}
/** Wait until `test(snapshot)` holds, the time runs out, or the turn is superseded. */
function waitFor(layer, test, { timeoutMs, isCurrent }) {
return new Promise((resolve) => {
let done = false;
let timer = null;
let unsubscribe = () => {};
const finish = (value) => {
if (done) return;
done = true;
clearTimeout(timer);
clearInterval(poll);
unsubscribe();
resolve(value);
};
const check = () => {
if (!isCurrent()) return finish('cancelled');
const snapshot = layer.getSnapshot();
if (test(snapshot)) finish('ready');
};
unsubscribe = layer.subscribe?.(check) || (() => {});
const poll = setInterval(check, 250);
timer = setTimeout(() => finish('timeout'), timeoutMs);
check();
});
}
/**
* Run one `find_imagery` call.
* @param {object} deps
* @param {object} deps.dataManager
* @param {object} deps.viewer
* @param {(areaId: string) => object|null} deps.getArea Stored area by id.
* @param {(enabled: boolean) => Promise<object>} deps.enableLayer set_layer_visibility for the layer.
* @param {(step: string, label?: string) => void} [deps.progress] Step reports.
* @param {object} args Tool arguments; `center` ({lat, lon}) is the resolved
* pointer, never a model argument.
* @param {{isCurrent?: () => boolean, signal?: AbortSignal}} [options]
*/
export async function findImagery(
{
dataManager,
viewer,
getArea,
enableLayer,
progress = () => {},
now = () => Date.now(),
},
args = {},
{ isCurrent = () => true, signal = null } = {},
) {
const fail = (code, error, extra = {}) => ({
ok: false,
action: 'find_imagery',
code,
error,
...extra,
});
const current = () => !signal?.aborted && isCurrent();
const layer = dataManager?.layers?.get(LAYER_ID)?.module;
if (!layer?.setBox || !layer?.getSnapshot)
return fail(
'LAYER_UNAVAILABLE',
'The Recent Imagery layer is not available.',
);
const range = imageryDateRange(args.dateRange, now());
if (range?.error) return fail('BAD_VALUE', range.error);
const maxCloudPct =
args.maxCloudPct === undefined || args.maxCloudPct === null
? null
: Math.max(0, Math.min(100, Number(args.maxCloudPct)));
if (maxCloudPct !== null && !Number.isFinite(maxCloudPct))
return fail('BAD_VALUE', 'maxCloudPct must be 0–100.');
// The area, resolved before anything changes on screen.
const center =
Number.isFinite(args.center?.lat) && Number.isFinite(args.center?.lon)
? { lat: args.center.lat, lon: args.center.lon }
: null;
const where = args.areaId ? 'area' : center ? 'point' : 'view';
// An unresolved sentinel never searches the view in its place.
if (!args.areaId && !center && args.area === 'pointer')
return fail('NO_POINTER', 'Point at the spot on the map first.');
let box = null;
let areaName = null;
if (where === 'area') {
const record = getArea(args.areaId);
if (!record)
return fail(
'AREA_UNKNOWN',
`No area "${args.areaId}" on this page — call resolve_area again.`,
);
const [west, south, east, north] = record.bbox;
if (west > east)
return fail(
'DATELINE',
`${record.name} crosses the date line; the imagery box needs one side of it.`,
);
// 30 m pixels over a landmark-sized area show a handful of blobs: a small
// area gets the layer's own 10 km box around its centre instead.
const small =
(north - south) * 111 < 5 &&
(east - west) * 111 * Math.cos((((north + south) / 2) * Math.PI) / 180) <
5;
box = small
? { pin: { lon: (west + east) / 2, lat: (south + north) / 2 } }
: { west, south, east, north };
areaName = record.name;
} else if (where === 'point') {
box = { pin: center };
areaName = args.centerLabel
? String(args.centerLabel)
: 'the spot under the pointer';
}
if (!current())
return fail('CANCELLED', 'This request was superseded.', {
cancelled: true,
});
const offWatch = watchExplicitLayerOff(dataManager, LAYER_ID);
let stopCallerAbort = () => {};
const displaced = () =>
offWatch.requested()
? displacedLayerResult(
dataManager,
LAYER_ID,
'find_imagery',
'Recent Imagery was turned off while I searched, so I left it off and discarded the result.',
)
: fail(
'DISPLACED',
'The imagery panel was changed by hand while I searched, so I left it as it is.',
{ cancelled: true, ...(areaName ? { area: areaName } : {}) },
);
try {
if (!dataManager.isEnabled?.(LAYER_ID)) {
const enabled = await enableLayer(true);
if (offWatch.requested()) return displaced();
if (!current())
return fail('CANCELLED', 'This request was superseded.', {
cancelled: true,
});
if (enabled?.ok === false)
return fail(
'LAYER_OFF',
enabled.error || 'Recent Imagery could not be turned on.',
);
}
const sources =
args.source === 'hls'
? { hls: true, viirs: false }
: args.source === 'viirs'
? { hls: false, viirs: true }
: args.source === 'any'
? { hls: true, viirs: true }
: { hls: true };
// The operator's own settings, put back if the box is refused.
const before = layer.getSnapshot();
layer.setSources(sources);
layer.setMode?.('image');
const accepted = box?.pin
? layer.boxFromPinAt(box.pin.lon, box.pin.lat)
: box
? layer.setBox(box)
: layer.useCurrentView(viewer);
if (!accepted) {
const reason =
layer.getSnapshot().boxError || 'That area cannot be searched.';
if (before?.sources) layer.setSources(before.sources);
if (before?.mode) layer.setMode?.(before.mode);
return fail('BOX_REFUSED', `${reason}. Zoom in or pick a smaller area.`, {
...(areaName ? { area: areaName } : {}),
});
}
// This request owns the panel state it just set. The operator can take
// the panel back at any moment (a new box, CLEAR, a pin, a source or mode
// switch); from then on it is theirs and this request stops without
// touching it. Checked in every wait and before every write.
let owned = panelOwnershipKey(layer.getSnapshot());
const isOurs = (snap) =>
!offWatch.requested() && panelOwnershipKey(snap) === owned;
const cancelOwnedSearch = () => {
if (isOurs(layer.getSnapshot())) layer.cancelPendingSearch?.();
};
signal?.addEventListener?.('abort', cancelOwnedSearch, { once: true });
stopCallerAbort = () =>
signal?.removeEventListener?.('abort', cancelOwnedSearch);
if (signal?.aborted) cancelOwnedSearch();
progress('catalog', areaName || 'the view');
const searched = await waitFor(
layer,
(snap) => !snap.searching || !isOurs(snap),
{ timeoutMs: SEARCH_WAIT_MS, isCurrent: current },
);
if (searched === 'cancelled')
return fail('CANCELLED', 'This request was superseded.', {
cancelled: true,
});
let snapshot = layer.getSnapshot();
if (!isOurs(snapshot)) return displaced();
if (searched === 'timeout' || snapshot.searching)
return fail(
'SEARCH_TIMEOUT',
'The imagery catalog is slow — the panel will fill in when it answers.',
);
if (snapshot.error && !snapshot.candidates.length)
return fail('CATALOG_UNAVAILABLE', snapshot.error);
const { eligible, best } = selectImagery(snapshot.candidates, {
...(range || {}),
maxCloudPct,
truncated: snapshot.notes?.some((note) => /truncated/i.test(note)),
});
const windowNote = 'the catalog covers the last 30 days';
if (!best) {
return {
ok: true,
action: 'find_imagery',
count: 0,
total: snapshot.candidates.length,
shown: null,
say: `No imagery matches${maxCloudPct !== null ? ` under ${maxCloudPct}% cloud` : ''} — ${windowNote}.`,
display: { window: windowNote },
};
}
// Re-checked at the commit: the candidate list must still be this box's.
if (!current())
return fail('CANCELLED', 'This request was superseded.', {
cancelled: true,
});
if (!isOurs(layer.getSnapshot())) return displaced();
const pinned =
layer.setAssignment('a', best.key) ||
layer.getSnapshot().pins?.a?.key === best.key;
owned = panelOwnershipKey(layer.getSnapshot());
if (!pinned)
return fail(
'PIN_REJECTED',
`The ${best.day} acquisition could not be shown — it has no pixels over this area.`,
);
const pinIsOurs = (snap) => isOurs(snap) && snap.pins?.a?.key === best.key;
progress('drape', best.day);
const draped = await waitFor(
layer,
(snap) => snap.shown?.a === best.key || !pinIsOurs(snap),
{ timeoutMs: DRAPE_WAIT_MS, isCurrent: current },
);
if (draped === 'cancelled')
return fail('CANCELLED', 'This request was superseded.', {
cancelled: true,
});
snapshot = layer.getSnapshot();
// A pin the operator replaced, or the layer dropped as empty, is not "still
// loading" — it is not ours any more.
if (!pinIsOurs(snapshot)) return displaced();
const onMap = snapshot.shown?.a === best.key;
const product = PRODUCTS[best.product];
const cloudPct = best.cloud ? round(best.cloud.max) : null;
// The catalog stops at its record cap; say so rather than imply it is all.
const capped = snapshot.notes?.find((note) => /truncated/i.test(note));
const date = best.timeRange?.start || best.day;
return {
ok: true,
action: 'find_imagery',
count: eligible.length,
total: snapshot.candidates.length,
shown: {
date: best.day,
source: product?.name || best.product,
resolutionM: product?.resolutionM ?? null,
cloudPct,
onMap,
},
...(areaName ? { area: areaName } : {}),
say: `${product?.name || best.product} from ${best.day}${cloudPct !== null ? `, ${cloudPct}% cloud` : ''}${onMap ? '' : ' — still loading'}`,
display: {
acquired: date,
window: windowNote,
...(capped ? { caveat: capped } : {}),
...(snapshot.pins?.a?.label ? { label: snapshot.pins.a.label } : {}),
},
};
} finally {
stopCallerAbort();
offWatch.stop();
}
}
+532
View File
@@ -0,0 +1,532 @@
// find_imagery over the Recent Imagery layer: which acquisition is chosen,
// that it is shown through the layer's own pin, and the honest refusals.
//
// Run with: npm test
import test from 'node:test';
import assert from 'node:assert/strict';
import {
findImagery,
imageryDateRange,
selectImagery,
} from './imageryActions.js';
import { createAreaStore } from '../data/areaStore.js';
const NOW = Date.parse('2026-09-23T12:00:00Z');
const hls = (day, cloud, extra = {}) => ({
key: `S30:${day}`,
product: 'S30',
day,
granules: [{ cloud, timeStart: `${day}T17:00:00Z` }],
cloud: { min: cloud, max: cloud },
timeRange: { start: `${day}T17:00:00Z`, end: `${day}T17:05:00Z` },
availability: 'present',
coverage: 'full',
...extra,
});
const CANDIDATES = [
hls('2026-09-21', 80),
hls('2026-09-18', 12),
hls('2026-09-10', 3),
hls('2026-09-05', 0, { availability: 'empty' }),
];
/** A stand-in for the layer: the same method names and snapshot fields. */
function fakeLayer({
candidates = CANDIDATES,
refuse = null,
error = null,
} = {}) {
let snapshot = {
searching: false,
candidates: [],
box: null,
boxError: null,
error: null,
pins: { a: { key: null } },
shown: { a: null },
notes: [],
sources: { hls: false, viirs: true },
mode: 'basemap',
};
const listeners = new Set();
const calls = [];
let searchTimer = null;
const set = (patch) => {
snapshot = { ...snapshot, ...patch };
for (const l of listeners) l(snapshot);
};
const search = (box) => {
set({ box, searching: true, candidates: [] });
searchTimer = setTimeout(
() => set({ searching: false, candidates, error }),
5,
);
return true;
};
return {
calls,
setBox(box) {
calls.push(['setBox', box]);
if (refuse) {
set({ boxError: refuse });
return false;
}
return search(box);
},
useCurrentView() {
calls.push(['useCurrentView']);
if (refuse) {
set({ boxError: refuse });
return false;
}
return search({ west: 0, south: 0, east: 1, north: 1 });
},
boxFromPinAt(lon, lat) {
calls.push(['boxFromPinAt', lon, lat]);
return search({
west: lon - 0.05,
south: lat - 0.05,
east: lon + 0.05,
north: lat + 0.05,
});
},
setSources(sources) {
calls.push(['setSources', sources]);
set({ sources: { ...sources } });
},
setMode(mode) {
calls.push(['setMode', mode]);
set({ mode });
},
setAssignment(slot, key) {
calls.push(['setAssignment', slot, key]);
set({ pins: { a: { key, label: `${key} label` } } });
setTimeout(() => set({ shown: { a: key } }), 5);
return true;
},
cancelPendingSearch() {
calls.push(['cancelPendingSearch']);
clearTimeout(searchTimer);
searchTimer = null;
set({ searching: false });
},
getSnapshot: () => snapshot,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
function deps(layer, { enabled = true, area = null } = {}) {
const toggles = [];
return {
toggles,
value: {
dataManager: {
layers: new Map([['recent-imagery', { module: layer }]]),
isEnabled: () => enabled,
},
viewer: {},
getArea: (id) => (area && id === area.areaId ? area : null),
enableLayer: async (on) => {
toggles.push(on);
return { ok: true };
},
now: () => NOW,
},
};
}
test('the newest clear acquisition is chosen and pinned through the layer', async () => {
const layer = fakeLayer();
const { value } = deps(layer);
const result = await findImagery(value, {});
assert.equal(result.ok, true);
assert.equal(result.count, 3, 'the confirmed-empty day is not a match');
assert.deepEqual(result.shown, {
date: '2026-09-18',
source: 'Sentinel-2',
resolutionM: 30,
cloudPct: 12,
onMap: true,
});
assert.equal(result.say, 'Sentinel-2 from 2026-09-18, 12% cloud');
assert.deepEqual(
layer.calls.map((c) => c[0]),
['setSources', 'setMode', 'useCurrentView', 'setAssignment'],
);
assert.deepEqual(layer.calls.at(-1), [
'setAssignment',
'a',
'S30:2026-09-18',
]);
});
test('filters on cloud and dates narrow the choice', async () => {
const cloudFree = await findImagery(deps(fakeLayer()).value, {
maxCloudPct: 5,
});
assert.equal(cloudFree.shown.date, '2026-09-10');
assert.equal(cloudFree.count, 1);
const lastWeek = await findImagery(deps(fakeLayer()).value, {
dateRange: { days: 7 },
});
assert.equal(lastWeek.shown.date, '2026-09-18');
const none = await findImagery(deps(fakeLayer()).value, {
dateRange: { from: '2026-09-22', to: '2026-09-23' },
});
assert.equal(none.ok, true);
assert.equal(none.count, 0);
assert.equal(none.shown, null);
assert.match(none.say, /last 30 days/);
});
test('an area handle searches its bounding box; the view and pointer have their own boxes', async () => {
const area = {
areaId: 'osm:r1',
name: 'Bagamati Province',
bbox: [83.9, 26.9, 86.6, 28.4],
};
const layer = fakeLayer();
const result = await findImagery(deps(layer, { area }).value, {
areaId: 'osm:r1',
});
assert.equal(result.area, 'Bagamati Province');
assert.deepEqual(layer.calls.find((c) => c[0] === 'setBox')[1], {
west: 83.9,
south: 26.9,
east: 86.6,
north: 28.4,
});
const tiny = fakeLayer();
await findImagery(
deps(tiny, {
area: {
areaId: 'a',
name: 'Ferry Building',
bbox: [-122.396, 37.793, -122.391, 37.798],
},
}).value,
{ areaId: 'a' },
);
assert.equal(
tiny.calls.find((c) => c[0] === 'boxFromPinAt')[0],
'boxFromPinAt',
'a landmark gets a 10 km box',
);
const pinned = fakeLayer();
// The runner resolves 'pointer' to the turn snapshot's point (deixis).
await findImagery(deps(pinned).value, {
center: { lat: 27.7, lon: 85.3 },
});
assert.deepEqual(
pinned.calls.find((c) => c[0] === 'boxFromPinAt'),
['boxFromPinAt', 85.3, 27.7],
);
const noPointer = await findImagery(deps(fakeLayer()).value, {
area: 'pointer',
});
assert.equal(noPointer.code, 'NO_POINTER');
const unknown = await findImagery(deps(fakeLayer()).value, {
areaId: 'osm:r9',
});
assert.equal(unknown.code, 'AREA_UNKNOWN');
});
test('a mixed antimeridian area gets the deliberate imagery date-line refusal', async () => {
const square = (w, s, e, n) => [
[w, s],
[e, s],
[e, n],
[w, n],
[w, s],
];
const crossing = [
[178, 0],
[-178, 0],
[-178, 1],
[178, 1],
[178, 0],
];
const store = createAreaStore();
const area = store.put({
geometry: [[crossing], [square(-176, 0.2, -174, 0.8)]],
name: 'Dateline Islands',
source: 'osm',
});
assert.deepEqual(area.bbox, [178, 0, -174, 1]);
const result = await findImagery(deps(fakeLayer(), { area }).value, {
areaId: area.areaId,
});
assert.equal(result.ok, false);
assert.equal(result.code, 'DATELINE');
});
test('a box the layer refuses is refused with its reason, and the layer is turned on first', async () => {
const layer = fakeLayer({ refuse: 'Box is 1,200 km wide · limit 1,000 km' });
const { value, toggles } = deps(layer, { enabled: false });
const result = await findImagery(value, {});
assert.equal(result.ok, false);
assert.equal(result.code, 'BOX_REFUSED');
assert.match(result.error, /limit 1,000 km/);
assert.deepEqual(toggles, [true]);
});
test('a refused box leaves the operator panel sources and mode as they were', async () => {
const layer = fakeLayer({ refuse: 'Box is 1,200 km wide · limit 1,000 km' });
const result = await findImagery(deps(layer).value, {});
assert.equal(result.code, 'BOX_REFUSED');
const snap = layer.getSnapshot();
assert.deepEqual(snap.sources, { hls: false, viirs: true });
assert.equal(snap.mode, 'basemap');
});
test('a superseded search is cancelled before anything is pinned', async () => {
const layer = fakeLayer();
let current = true;
const pending = findImagery(
deps(layer).value,
{},
{ isCurrent: () => current },
);
current = false;
const result = await pending;
assert.equal(result.code, 'CANCELLED');
assert.equal(
layer.calls.some((c) => c[0] === 'setAssignment'),
false,
);
});
test('an already-aborted search does not change the imagery panel', async () => {
const layer = fakeLayer();
const controller = new AbortController();
controller.abort();
const result = await findImagery(
deps(layer).value,
{},
{ signal: controller.signal },
);
assert.equal(result.code, 'CANCELLED');
assert.equal(result.cancelled, true);
assert.deepEqual(layer.calls, []);
});
test('caller abort stops the owned catalog search before a late render', async () => {
const layer = fakeLayer();
const controller = new AbortController();
const pending = findImagery(
deps(layer).value,
{},
{
signal: controller.signal,
isCurrent: () => !controller.signal.aborted,
},
);
controller.abort();
const result = await pending;
assert.equal(result.code, 'CANCELLED');
assert.equal(
layer.calls.some((call) => call[0] === 'cancelPendingSearch'),
true,
);
assert.equal(
layer.calls.some((call) => call[0] === 'setAssignment'),
false,
);
});
test('a catalog failure is not reported as "no imagery"', async () => {
const result = await findImagery(
deps(fakeLayer({ candidates: [], error: 'HLS S30 catalog unavailable' }))
.value,
{},
);
assert.equal(result.code, 'CATALOG_UNAVAILABLE');
});
test('date ranges and selection helpers', () => {
assert.deepEqual(imageryDateRange({ days: 7 }, NOW), {
from: NOW - 7 * 86_400_000,
to: NOW,
});
assert.equal(
imageryDateRange({ from: 'yesterday' }, NOW).error !== undefined,
true,
);
const to = imageryDateRange({ to: '2026-09-10' }, NOW).to;
assert.equal(new Date(to).toISOString(), '2026-09-10T23:59:59.999Z');
const { best } = selectImagery(CANDIDATES, { maxCloudPct: 20 });
assert.equal(best.day, '2026-09-18');
});
// ── Review regressions: the real layer, and the operator taking it back ──
import { createRecentImageryLayer } from '../layers/recentImagery/index.js';
import {
candidate,
fakeCatalog,
fakeRenderer,
fakeThumbnails,
} from '../layers/recentImagery/testDoubles.mjs';
const AREA_BOX = { west: 85.2, south: 27.6, east: 85.4, north: 27.8 };
const OTHER_BOX = { west: -97.8, south: 30.2, east: -97.7, north: 30.3 };
function realLayer() {
const catalog = fakeCatalog();
const thumbnails = fakeThumbnails();
const layer = createRecentImageryLayer({
catalog,
renderer: fakeRenderer(),
thumbnails,
host: () => ({ collection: {}, kind: 'globe' }),
now: () => new Date('2026-09-23T12:00:00Z'),
});
layer.init({ camera: {} });
layer.enable();
return { layer, catalog, thumbnails };
}
function realDeps(layer) {
return {
dataManager: {
layers: new Map([['recent-imagery', { module: layer }]]),
isEnabled: () => true,
},
viewer: {},
getArea: () => ({
areaId: 'osm:r1',
name: 'Kathmandu',
bbox: [AREA_BOX.west, AREA_BOX.south, AREA_BOX.east, AREA_BOX.north],
}),
enableLayer: async () => ({ ok: true }),
};
}
function ownedVisibilityDeps(layer) {
let enabled = true;
const requestListeners = new Set();
const settledListeners = new Set();
const dataManager = {
layers: new Map([['recent-imagery', { module: layer }]]),
isEnabled: () => enabled,
getLayerLifecycleState: () => ({
enabled,
lifecycleState: enabled ? 'enabled' : 'disabled',
uncertain: false,
}),
subscribeVisibilityRequests(listener) {
requestListeners.add(listener);
return () => requestListeners.delete(listener);
},
subscribe(listener) {
settledListeners.add(listener);
return () => settledListeners.delete(listener);
},
};
return {
...realDeps(layer),
dataManager,
requestOff({ settle = true } = {}) {
const change = {
type: 'visibility-requested',
layerId: 'recent-imagery',
enabled: false,
origin: 'user',
};
for (const listener of requestListeners) listener(change);
if (settle) {
enabled = false;
layer.disable();
for (const listener of settledListeners)
listener({ ...change, type: 'visibility-changed' });
}
},
activeVisibilityListeners: () =>
requestListeners.size + settledListeners.size,
};
}
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
test('a box changed by hand mid-search is left alone, not pinned under the old name', async () => {
const { layer, catalog } = realLayer();
const pending = findImagery(realDeps(layer), { areaId: 'osm:r1' });
await tick();
assert.equal(catalog.searches.length, 1, 'searching the area box');
layer.setBox(OTHER_BOX); // the operator picks another box
catalog.resolveLast([candidate('S30', '2026-09-18', 3)]);
const result = await pending;
assert.equal(result.ok, false);
assert.equal(result.code, 'DISPLACED');
assert.equal(result.cancelled, true);
assert.equal(
layer.getSnapshot().pins.a.key,
null,
'nothing pinned on their box',
);
});
test('a pin replaced by hand while it loads is not narrated as still loading', async () => {
const { layer, catalog } = realLayer();
const slow = {
...candidate('S30', '2026-09-18', 3),
availability: 'unknown',
};
const other = candidate('L30', '2026-09-16', 5);
const pending = findImagery(realDeps(layer), { areaId: 'osm:r1' });
await tick();
catalog.resolveLast([slow, other]);
await tick();
await tick();
assert.equal(
layer.getSnapshot().pins.a.key,
slow.key,
'voice pinned its choice',
);
layer.setAssignment('a', other.key); // the operator shows another day
const result = await pending;
assert.equal(result.code, 'DISPLACED');
assert.equal(layer.getSnapshot().pins.a.key, other.key, 'their pin stays');
});
test('with nobody touching the panel the real layer shows the chosen day', async () => {
const { layer, catalog } = realLayer();
const pending = findImagery(realDeps(layer), { areaId: 'osm:r1' });
await tick();
catalog.resolveLast([
candidate('S30', '2026-09-18', 3),
candidate('L30', '2026-09-10', 60),
]);
const result = await pending;
assert.equal(result.ok, true);
assert.equal(result.shown.date, '2026-09-18');
assert.equal(result.shown.onMap, true);
assert.equal(layer.getSnapshot().shown.a, 'S30:2026-09-18');
});
test('manual OFF owns a pending imagery search before its fake catalog completes', async () => {
const { layer, catalog } = realLayer();
const deps = ownedVisibilityDeps(layer);
const pending = findImagery(deps, { areaId: 'osm:r1' });
await tick();
assert.equal(catalog.searches.length, 1);
deps.requestOff({ settle: false });
catalog.resolveLast([candidate('S30', '2026-09-18', 3)]);
const result = await pending;
assert.equal(result.ok, false);
assert.equal(result.code, 'DISPLACED');
assert.equal(result.cancelled, true);
assert.equal(result.requestedEnabled, false);
assert.equal(
layer.getSnapshot().pins.a.key,
null,
'late result is not pinned',
);
assert.equal(result.say, undefined, 'no success narration is published');
assert.equal(deps.activeVisibilityListeners(), 0, 'ownership watchers stop');
});
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more