diff --git a/.env.example b/.env.example index b3de4cefa..169ec66e3 100644 --- a/.env.example +++ b/.env.example @@ -238,6 +238,8 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL # CCTV_CATALONIA_ENABLED=1 # set 0 to disable the Servei Català de Trànsit cameras # CCTV_CATALONIA_MAX_SOURCES=150 # cap cameras loaded from Catalonia (~130 exist today) +# CCTV_ICELAND_ENABLED=1 # set 0 to disable the Vegagerðin (Iceland) road cameras +# CCTV_ICELAND_MAX_SOURCES=250 # cap cameras loaded from Iceland (~500 views exist today) # CCTV_HK_ENABLED=1 # set 0 to disable the Hong Kong Transport Department cameras # CCTV_HK_MAX_SOURCES=1100 # cap cameras loaded from Hong Kong (~1,013 exist today); min 8, max 1200 # CCTV_HK_CAMERAS_URL= # override the DATA.GOV.HK camera locations XML URL diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index ed0895b0a..e97d2f021 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -41,6 +41,8 @@ How to read this: | **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app | | **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" | | **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Prohibited & restricted airspace: FAA Special Use Airspace" | +| **iNaturalist API** (`api.inaturalist.org/v2/observations`, `/v1/grid/{z}/{x}/{y}.grid.json`) | Wildlife Sightings layer: verifiable wildlife observed within the last 48 h (whales 7 d), by observation time, in the current view, with taxon, observation time, quality grade and eligible photo | Per-record licences; observations default CC BY-NC. Photos CC0/BY/BY-SA/BY-NC/BY-NC-SA shown; ND and all-rights-reserved photos link-only. Keyless via `/api/inaturalist/observations` (5-min cache; global sample 10 min; stale ≤ 1 h; 60 requests/min and 8,000/day upstream caps). **NonCommercial content:** commercial redistributors must filter to CC0/BY/BY-SA or remove the layer. No AI training on this content | "Wildlife sightings: iNaturalist contributors (licences per record; photos credited on each card)" | +| **Acartia** (`acartia.io/api/v1/sightings/current`) | Wildlife Sightings layer: Salish Sea marine mammal reports with validated species, observation time, reporting provider and trust flag; WHALES includes only the frozen cetacean list | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), each provider credited per sighting. Keyless via `/api/acartia/sightings` (10-min cache; stale ≤ 24 h). Positions rounded to 0.01° on the server; free-text comments excluded. Marine-conservation use; Be Whale Wise | "Wildlife sightings: Acartia (CC BY 4.0; each provider credited per sighting) · Be Whale Wise" | | **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles | | **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation | | **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://docs.tomtom.com/legal/terms-and-conditions) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates | @@ -63,6 +65,7 @@ How to read this: | **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" | | **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" | | **Servei Català de Trànsit** (+ Ajuntament de Barcelona, Ajuntament de Terrassa) | CCTV camera catalog + frames, Catalonia | [Llicència oberta d'ús d'informació - Catalunya](https://web.gencat.cat/ca/generalitat/dades-indicadors/dades-obertes/llicencies) — attribution REQUIRED | "Servei Català de Trànsit"; partner-supplied cameras also name their owner | +| **Icelandic Road and Coastal Administration (Vegagerðin, IRCA)** | CCTV camera catalog + frames, Iceland road cameras | [CC BY 4.0](https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions) (IRCA terms of use, Act 45/2018) — attribution REQUIRED; no suggestion of IRCA endorsement; IRCA and State logos are excluded; real-time data is provided as is | "Icelandic Road and Coastal Administration (Vegagerðin)", CC BY 4.0 | | **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) | | **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) | | **Hong Kong Transport Department / DATA.GOV.HK** | CCTV camera catalog + frames, Hong Kong traffic snapshot cameras | [DATA.GOV.HK Terms of Use](https://data.gov.hk/en/terms-and-conditions) — attribution REQUIRED | "Hong Kong Transport Department / DATA.GOV.HK" | @@ -176,6 +179,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. - **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent. - **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`. +- **Iceland (Vegagerðin / IRCA) cameras.** The camera list is the keyless `https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` JSON feed (about 500 camera views at roughly 170 stations), fetched over https with redirects refused and the body read capped at 2 MiB, since the list decides ids, coordinates and which frame path is proxied. Frames are JPEG stills in `https://www.vegagerdin.is/vgdata/vefmyndavelar/`; only links inside that directory are registered (userinfo, explicit ports and encoded path separators are rejected). Camera ids come from the frame file name. The feed's `Skyring` view description is the camera label, and a stated facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading; a view that states none, or two different ones ("niður á veg", a bare road direction), takes the id-hash low-confidence pose. The feed has no elevation, so cameras carry a flat prior that the client's ground snap corrects. IRCA publishes road camera images under CC BY 4.0 and requires the licensor to be acknowledged without implying endorsement; the pack shows the attribution above (licensor, dataset, CC BY 4.0, terms link and no-endorsement note) in the Data attribution popover, and every camera record carries the licence string. IRCA describes the images as fetched several times per hour (a live frame's `Last-Modified` was about four minutes old on 2026-11-06) and the real-time data as provided as is, without warranty. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_ICELAND_ENABLED=0`. - **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`. - **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here. - **Hong Kong Transport Department traffic snapshot cameras.** Keyless. The English camera list is the public DATA.GOV.HK XML at `static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml`; frames are stills on `tdcctv.data.one.gov.hk`, and only that host is registered. Frame URLs are rebuilt from each camera key (`https://tdcctv.data.one.gov.hk/{KEY}.JPG`) rather than taken verbatim from the catalog. Stills were probed to answer directly on that origin with no redirect hop. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default nearly the full catalog loads (~1,013; pack cap 1,100), prioritized nearest Central, Tsim Sha Tsui, Sha Tin, Tsuen Wan, Tai Po, Fanling, Tuen Mun, Yuen Long and Tseung Kwan O so a lowered cap still spans Hong Kong Island, Kowloon and the New Territories. When the sum of packs exceeds the catalog-wide ceiling, shared round-robin keeps every lane represented. `CCTV_HK_MAX_SOURCES` (8–1200) changes the cap and `CCTV_HK_ENABLED=0` turns the pack off. The DATA.GOV.HK Terms of Use require attribution to the Government / Transport Department / DATA.GOV.HK. @@ -195,6 +199,9 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable. - **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit. - **FAA Special Use Airspace.** Keyless. `/api/drone-airspace` queries the public FAA AIS FeatureServer for prohibited (P) and restricted (R) zones only — not MOAs, TFRs or other SUA types — with simplified geometry, a body cap, paging, a one-hour cache and stale-on-error. The layer is US coverage, informational only, and is not a substitute for B4UFLY, LAANC or a preflight briefing. Data is fetched at request time and never stored or redistributed. US government public domain; courtesy attribution is registered in the Data attribution popover. +- **iNaturalist.** Keyless. `/api/inaturalist/observations` returns cached random view samples (newest first globally), at most 200 per request. Recency uses observation time: the v1 `d1` filter and a rolling 48 h cutoff (whales 7 d); date-only rows qualify when their day starts within the window. Observation caches last 5 min (global 10 min), with stale fallback ≤1 h. `/api/inaturalist/grid` supplies density counts, cached 15 min. Both share a paced queue capped at 60/min and 8,000/day; only request counters persist in `.gev-cache/`. The server caches projected records, never raw responses. Private records are excluded; obscured coordinates become 0.2° cells. Observer identity is retained only for photo attribution. Eligible photos load directly from `inaturalist-open-data.s3.amazonaws.com`, with creator credit on ambient cards and creator/licence on selection. Per-record licences and noncommercial restrictions apply as listed above. Attribution appears in the layer row and Data attribution popover. Coverage follows community observations, not a census; Acartia adds Salish Sea marine mammals. +- **iNaturalist grid (cell schema risk).** `/api/inaturalist/grid` shares observation-date, taxa, quality, pacing and budget rules with observations. A strict 64×64 UTFGrid schema guard fails closed; only logical-cell keys, counts and coordinates survive projection. Raw tiles, observation identifiers and free text are not retained. Projected tiles use a 400-entry LRU, 15-minute freshness and at most one hour of stale fallback. Views request at most six tiles; cached and adjacent-zoom coverage publishes before network waits, independently of observation pages. Cells supply a noninteractive density glow ranked within the view, from teal to amber with mono/CRT support. Every sighting badge comes from a real observation at its public position. Counts describe observations, not a population census. The endpoint is documented in the v1 and v2 API docs as “Grid Tiles UTFGrid” (`/grid/{z}/{x}/{y}.grid.json`), but its per-cell records are not specified; schema drift disables grids until revalidated. See `docs/KNOWN-ISSUES.md`. +- **Acartia.** Keyless. `/api/acartia/sightings` reads the current 7-day marine-mammal feed for ALL, MAMMALS and WHALES views intersecting 45.5–50.5° N, 126–121° W in the Salish Sea. WHALES accepts recognized cetaceans; other identified marine mammals appear under ALL/MAMMALS. The server rounds positions to 0.01° and drops comments before caching projected records (10 min fresh, stale ≤24 h); raw responses are not retained. Selected text cards credit the reporting provider under CC BY 4.0 and show Be Whale Wise guidance. `/sightings/current` answers without a key today, but Acartia's README asks API users to create an account and generate an API key on the [Acartia dashboard](https://acartia.io/) and to follow its [community guidelines](https://acartia.io/register). Attribution also appears in the layer row and Data attribution popover. Opportunistic sightings, not a census. - **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. @@ -212,7 +219,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | Dataset | Folder | License | Commercial use? | Attribution | | --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- | -| **Datacenters** (4,351) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) | +| **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) | | **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map | | **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation | | **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" | diff --git a/README.md b/README.md index 6212f3991..8af137fcb 100644 --- a/README.md +++ b/README.md @@ -426,7 +426,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station ## 🛰️ What's on the Globe -Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) +Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) | Layer | What you get | Source | Auth | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | @@ -440,7 +440,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Iceland (Vegagerðin) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | @@ -452,6 +452,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer | 🚀 **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 | 🟢 | | 🚫 **Prohibited & Restricted Airspace** | FAA prohibited (P) and restricted (R) special-use airspace as ground polygons, with name, altitude range and times of use on select. Not the UAS Facility Map or UAS flight restrictions; informational only, not for flight planning | FAA SUA | 🟢 | +| 🦉 **Wildlife Sightings** | Wildlife observed within 48 h (whales 7 d): density glow, taxon badges and anchored photo cards with creator/licence credit. Salish Sea marine mammals via Acartia; sensitive locations shown as 0.2° cells. Not a census | iNaturalist, Acartia | 🟢 | | 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 | | 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 | | 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 | @@ -472,7 +473,7 @@ In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Ev _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×._ -**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,351), Dams (704), and Submarine Cables (712). +**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,352), Dams (704), and Submarine Cables (712). ![Diving into the Bahamas and revealing labeled submarine cable routes beneath the globe](docs/media/09-undersea-cables.gif) @@ -484,9 +485,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED > Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token), **Google Street View** (your Google Maps key), **KartaView** and **Panoramax** (no key). -**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless. +**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it Street Level starts on **KartaView** instead, and the Mapillary chip says which key to add. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless. -**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. +**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default when its token is set; otherwise KartaView is. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. diff --git a/SECURITY.md b/SECURITY.md index 4dceaed6f..7b894c346 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -81,7 +81,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur - **Sanitized errors** — internal error details are not echoed back to clients. - **Coalesced OAuth refresh** and cached successful responses only (OpenSky). - **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing. -- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an ``/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/drone-airspace`, `/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. +- **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 ``/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/drone-airspace`, `/api/inaturalist/observations`, `/api/acartia/sightings`, `/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 ` diff --git a/src/ui/wildlifeSightingCard.js b/src/ui/wildlifeSightingCard.js new file mode 100644 index 000000000..390aedc47 --- /dev/null +++ b/src/ui/wildlifeSightingCard.js @@ -0,0 +1,270 @@ +import { h, nextTick, shallowRef } from 'vue'; +import WildlifeSightingCard from './views/panels/WildlifeSightingCard.vue'; +import { + panWildlifeSighting, + placeWildlifeSightingCard, + projectWildlifeSighting, + readWildlifeCardSafeArea, + WILDLIFE_SELECTED_CARD_WIDTH, + WILDLIFE_CARD_CHROME_SELECTORS, +} from './wildlifeSightingPlacement.js'; + +/** Anchor the selected sighting's photo and source link clear of map chrome. */ +export function createWildlifeSightingCard({ + layer, + ui, + viewer = null, + documentRef = globalThis.document, + windowRef = globalThis.window, + now = () => Date.now(), + project = projectWildlifeSighting, + pan = panWildlifeSighting, +}) { + const selected = shallowRef(null); + const host = documentRef.createElement('div'); + host.dataset.wildlifeSightingCardHost = ''; + Object.assign(host.style, { + position: 'fixed', + zIndex: '89', + display: 'none', + width: `${WILDLIFE_SELECTED_CARD_WIDTH}px`, + }); + documentRef.body.append(host); + let destroyed = false; + let moving = false; + let removeFrame = null; + let returnFocus = null; + let panForId = null; + const removers = []; + const observed = new Set(); + const resizeObserver = windowRef?.ResizeObserver + ? new windowRef.ResizeObserver(() => position()) + : null; + const mutationObserver = windowRef?.MutationObserver + ? new windowRef.MutationObserver(() => position()) + : null; + + const chromeSelector = WILDLIFE_CARD_CHROME_SELECTORS.join(','); + const structureObserver = windowRef?.MutationObserver + ? new windowRef.MutationObserver((records) => { + if (destroyed || !selected.value) return; + const hasChromeChange = records.some((record) => + [...record.addedNodes, ...record.removedNodes].some( + (node) => + node.nodeType === 1 && + !host.contains(node) && + (node.matches(chromeSelector) || + node.querySelector(chromeSelector)), + ), + ); + if (!hasChromeChange) return; + // Newly mounted chrome can cover a previously clear anchor. + panForId = null; + position({ allowPan: true }); + }) + : null; + + function observeLayout(elements) { + const next = new Set( + [host.firstElementChild, viewer?.scene?.canvas, ...elements].filter( + Boolean, + ), + ); + let changed = false; + for (const element of observed) { + if (next.has(element)) continue; + resizeObserver?.unobserve(element); + observed.delete(element); + changed = true; + } + for (const element of next) { + if (observed.has(element)) continue; + observed.add(element); + resizeObserver?.observe(element); + changed = true; + } + if (!changed) return; + mutationObserver?.disconnect(); + for (const element of observed) { + // Ignore HUD clock/text changes and the card's own positioning styles. + if (!host.contains(element)) + mutationObserver?.observe(element, { + attributes: true, + attributeFilter: ['class', 'style', 'hidden'], + }); + } + } + + function position({ allowPan = false } = {}) { + if (destroyed || !selected.value || !host.firstElementChild) return; + const point = project(viewer, selected.value, layer); + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + host.style.display = 'none'; + return; + } + const safe = readWildlifeCardSafeArea({ documentRef, viewer, windowRef }); + observeLayout(safe.elements); + host.style.width = `${Math.min(WILDLIFE_SELECTED_CARD_WIDTH, safe.bounds.width)}px`; + host.style.maxHeight = `${safe.bounds.height}px`; + host.style.display = 'block'; + const rect = host.firstElementChild.getBoundingClientRect(); + const result = placeWildlifeSightingCard( + point, + { + width: + rect.width || + Math.min(WILDLIFE_SELECTED_CARD_WIDTH, safe.bounds.width), + height: + rect.height || + (selected.value.photo && + !selected.value.obscured && + selected.value.origin !== 'acartia' + ? 300 + : 132), + }, + safe, + ); + if (!result) { + host.style.display = 'none'; + return; + } + if ( + (result.pan.x || result.pan.y) && + allowPan && + panForId !== selected.value.stableId + ) { + panForId = selected.value.stableId; + const reduced = + windowRef?.matchMedia?.('(prefers-reduced-motion: reduce)').matches === + true; + pan(viewer, point, result.anchor, reduced); + } + host.style.left = `${result.left}px`; + host.style.top = `${result.top}px`; + host.dataset.placement = result.placement; + } + + const unmount = ui.mount( + { + setup: () => () => + selected.value + ? h(WildlifeSightingCard, { + sighting: selected.value, + nowMs: now(), + taxa: layer.getParams?.()?.taxa, + onClose: () => { + if (!destroyed) layer.clearSightingSelection(); + }, + onResize: () => position(), + }) + : null, + }, + host, + ); + + function restoreFocus() { + if (returnFocus?.isConnected) returnFocus.focus?.({ preventScroll: true }); + returnFocus = null; + } + const unsubscribe = layer.subscribeSelection((row, metadata) => { + if (destroyed) return; + const fresh = row?.stableId !== selected.value?.stableId; + if (row && !selected.value) { + returnFocus = documentRef.activeElement; + structureObserver?.observe(documentRef.body, { + childList: true, + subtree: true, + }); + } + selected.value = row; + if (!row) { + host.style.display = 'none'; + panForId = null; + removeFrame?.(); + removeFrame = null; + resizeObserver?.disconnect(); + mutationObserver?.disconnect(); + structureObserver?.disconnect(); + observed.clear(); + restoreFocus(); + return; + } + if (fresh) panForId = null; + nextTick(() => { + if (destroyed || !selected.value) return; + position({ allowPan: fresh || metadata?.explicitSelection === true }); + if (moving) startFrameUpdates(); + // Focus the container so keyboard navigation starts inside the card. + if (fresh) + host + .querySelector('[data-wildlife-sighting-card]') + ?.focus?.({ preventScroll: true }); + }); + }); + + const keydown = (event) => { + if (!selected.value || event.key !== 'Escape') return; + event.preventDefault(); + event.stopImmediatePropagation(); + layer.clearSightingSelection(); + }; + const outside = (event) => { + if (!selected.value || host.contains(event.target)) return; + // The layer selects on pointer-up; its following click must keep the card open. + const canvas = viewer?.scene?.canvas; + if (canvas && (event.target === canvas || canvas.contains?.(event.target))) + return; + layer.clearSightingSelection(); + }; + documentRef.addEventListener('keydown', keydown, true); + documentRef.addEventListener('click', outside, true); + const onResize = () => position(); + windowRef?.addEventListener('resize', onResize); + + function listen(event, callback) { + const remove = event?.addEventListener?.(callback); + if (typeof remove === 'function') removers.push(remove); + else if (event?.removeEventListener) + removers.push(() => event.removeEventListener(callback)); + } + function startFrameUpdates() { + if (!selected.value || removeFrame) return; + // Installed only for camera motion; an idle card has zero per-frame work. + removeFrame = + viewer?.scene?.preRender?.addEventListener?.(() => position()) || null; + } + listen(viewer?.camera?.moveStart, () => { + moving = true; + startFrameUpdates(); + }); + listen(viewer?.camera?.moveEnd, () => { + moving = false; + removeFrame?.(); + removeFrame = null; + position(); + }); + listen(viewer?.camera?.changed, () => { + if (!moving) position(); + }); + + return { + /** Release the card, selection subscription and placement listeners. */ + destroy() { + if (destroyed) return; + destroyed = true; + unsubscribe?.(); + for (const remove of removers) remove(); + removeFrame?.(); + resizeObserver?.disconnect(); + mutationObserver?.disconnect(); + structureObserver?.disconnect(); + documentRef.removeEventListener('keydown', keydown, true); + documentRef.removeEventListener('click', outside, true); + windowRef?.removeEventListener('resize', onResize); + unmount(); + host.remove(); + selected.value = null; + restoreFocus(); + }, + }; +} diff --git a/src/ui/wildlifeSightingCard.test.mjs b/src/ui/wildlifeSightingCard.test.mjs new file mode 100644 index 000000000..f685c75e0 --- /dev/null +++ b/src/ui/wildlifeSightingCard.test.mjs @@ -0,0 +1,797 @@ +import '../testSupport/registerDom.mjs'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { nextTick } from 'vue'; +import { readFileSync } from 'node:fs'; +import { getLocale, setLocale, t } from '../i18n/index.js'; +import { LOCALES } from '../i18n/locale.js'; +import { createSelection } from '../layers/wildlifeSightings/selection.js'; +import { buildWildlifeThumbnailEntry } from '../layers/wildlifeSightings/cards.js'; +import { createWildlifeSightingCard } from './wildlifeSightingCard.js'; +import { + panWildlifeSighting, + placeWildlifeSightingCard, + readWildlifeCardSafeArea, +} from './wildlifeSightingPlacement.js'; +import { createUiRuntime } from './vueRuntime.js'; +import { PANEL_RAIL_ITEMS } from './panelRailCatalog.js'; +import { sheetForPanelId } from './mobileShell.js'; + +const NOW = Date.parse('2026-11-04T13:00:00Z'); +const square = + 'https://inaturalist-open-data.s3.amazonaws.com/photos/456/square.jpg'; +const row = { + stableId: 'inat:456', + origin: 'inaturalist', + lat: 0, + lon: 0, + commonName: 'Synthetic bird', + scientificName: 'Avis imaginaria', + iconic: 'Aves', + observedAt: NOW - 3_600_000, + observedOn: '2026-11-04', + obscured: false, + photo: { + square, + attribution: '(c) Synthetic Photographer, some rights reserved (CC BY-SA)', + licence: 'cc-by-sa', + }, + link: 'https://www.inaturalist.org/observations/456', +}; +const rect = (left, top, width, height) => ({ + left, + top, + width, + height, + right: left + width, + bottom: top + height, +}); +const events = () => { + const listeners = new Set(); + return { + addEventListener(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + emit() { + for (const fn of listeners) fn(); + }, + size: () => listeners.size, + }; +}; +const settle = async () => { + await nextTick(); + await Promise.resolve(); + await nextTick(); +}; + +// All scene rows, media events and rectangles are synthetic. Browser image +// requests are disabled; decode resolution below proves the photo is exposed +// only after it has loaded into the compact card. +function fixture( + context, + initialRow = row, + point = { x: 600, y: 450 }, + extraWindow = {}, +) { + document.body.replaceChildren(); + const opener = document.body.appendChild(document.createElement('button')); + opener.textContent = 'Globe'; + opener.focus(); + const canvas = document.body.appendChild(document.createElement('canvas')); + canvas.getBoundingClientRect = () => rect(0, 0, 1200, 900); + const windowRef = { + innerWidth: 1200, + innerHeight: 900, + getComputedStyle: window.getComputedStyle.bind(window), + addEventListener() {}, + removeEventListener() {}, + matchMedia: () => ({ matches: false }), + ...extraWindow, + }; + const ui = createUiRuntime(); + const overlays = new Map(); + const moveStart = events(); + const moveEnd = events(); + const changed = events(); + const preRender = events(); + const viewer = { + scene: { canvas, preRender, pick: () => picked }, + camera: { moveStart, moveEnd, changed }, + }; + const state = { + enabled: true, + destroyed: false, + selectedId: null, + selectedRow: null, + ambientRows: [], + rowById: new Map([[initialRow.stableId, initialRow]]), + positions: new Map([[initialRow.stableId, { x: 1, y: 2, z: 3 }]]), + contextIds: new Set(), + viewer, + }; + let picked = null; + let hit = null; + let onClick; + let clears = 0; + let staleListener; + const subscriptions = new Set(); + const flights = []; + const pans = []; + const selection = createSelection({ + state, + services: { + now: () => NOW, + cesium: { ScreenSpaceEventType: { LEFT_CLICK: 0 } }, + overlayHost: { + setEntries: (id, entries) => overlays.set(id, entries), + hitTest: (_x, _y, { sourceId }) => + hit?.sourceId === sourceId ? hit : null, + }, + screenSpaceEventHandlerFactory: () => ({ + setInputAction(fn) { + onClick = fn; + }, + destroy() {}, + }), + focus: { requestWorldFocus: (value) => flights.push(value) }, + }, + parts: { + rendering: { refreshSelection() {}, requestRender() {} }, + }, + }); + const layer = { + getParams: () => ({ taxa: 'all' }), + subscribeSelection(listener) { + staleListener = listener; + subscriptions.add(listener); + const off = selection.subscribe(listener); + return () => { + subscriptions.delete(listener); + off(); + }; + }, + clearSightingSelection() { + clears++; + selection.clearAndRebuild(); + }, + }; + let currentPoint = point; + let projections = 0; + const card = createWildlifeSightingCard({ + layer, + ui, + viewer, + documentRef: document, + windowRef, + now: () => NOW, + project: () => { + projections++; + return currentPoint; + }, + pan: (_viewer, from, to, reduced) => { + pans.push({ from, to, reduced }); + currentPoint = to; + return true; + }, + }); + selection.install(); + context.after(() => { + card.destroy(); + selection.destroy(); + ui.destroy(); + }); + return { + state, + selection, + layer, + card, + viewer, + windowRef, + overlays, + flights, + pans, + projections: () => projections, + subscriptions, + opener, + canvas, + moveStart, + moveEnd, + preRender, + cardElement: () => document.querySelector('[data-wildlife-sighting-card]'), + host: () => document.querySelector('[data-wildlife-sighting-card-host]'), + staleListener: () => staleListener, + clears: () => clears, + async click(kind = 'dot', target = initialRow) { + picked = + kind === 'icon' + ? { primitive: { id: `wildlife:${target.stableId}` } } + : { id: `wildlife:${target.stableId}` }; + hit = + kind === 'ambient photo card' + ? { + sourceId: 'wildlife-sightings', + entry: buildWildlifeThumbnailEntry(target, NOW, { + select: selection.selectAndFocus, + }), + } + : null; + onClick({ position: { x: 50, y: 50 } }); + await settle(); + }, + async publish(next) { + if (next) { + state.rowById.set(next.stableId, next); + if (state.selectedId === next.stableId) state.selectedRow = next; + } + selection.notify(); + await settle(); + }, + obstacle(selector, bounds) { + const element = document.body.appendChild(document.createElement('div')); + if (selector.startsWith('#')) element.id = selector.slice(1); + else element.className = selector.slice(1); + element.getBoundingClientRect = () => bounds; + return element; + }, + setPoint(next) { + currentPoint = next; + }, + }; +} + +test('dot, icon and ambient photo clicks open one contained medium-photo card at the sighting', async (context) => { + for (const kind of ['dot', 'icon', 'ambient photo card']) + await context.test(kind, async (context) => { + const f = fixture(context); + await f.click(kind); + const card = f.cardElement(); + const image = card.querySelector('img'); + assert.equal(f.state.selectedId, row.stableId); + assert.equal( + document.querySelector('dialog'), + null, + 'there is no fullscreen or modal dialog', + ); + assert.equal(image.src, square.replace('/square.', '/medium.')); + assert.equal(image.referrerPolicy, 'no-referrer'); + assert.equal(card.querySelector('h2').textContent, row.commonName); + assert.equal(card.querySelector('i').textContent, row.scientificName); + assert.equal(card.querySelector('time').textContent, 'observed 1h ago'); + assert.ok( + card.textContent.includes('© Synthetic Photographer · CC BY-SA'), + ); + const link = card.querySelector('a'); + assert.equal(link.href, row.link); + assert.equal(link.target, '_blank'); + assert.equal(link.rel, 'noopener noreferrer'); + assert.equal( + link.getAttribute('aria-label'), + `Open ${row.commonName} on iNaturalist`, + ); + assert.equal( + link.querySelector('.wildlife-sighting-card-source').textContent, + 'iNaturalist ↗', + ); + assert.ok( + link.contains(image), + 'the photograph and text are one external action', + ); + assert.equal(f.flights.length, 1); + assert.equal(f.flights[0].kind, 'sighting'); + assert.deepEqual( + f.flights[0].position, + f.state.positions.get(row.stableId), + ); + assert.equal(f.pans.length, 0); + assert.equal( + f.preRender.size(), + 0, + 'an idle card installs no frame work', + ); + let decoded; + image.decode = () => + new Promise((resolve) => { + decoded = resolve; + }); + image.dispatchEvent(new Event('load')); + await settle(); + assert.equal( + image.classList.contains('loaded'), + false, + 'a request alone does not expose an empty frame', + ); + decoded(); + await settle(); + assert.equal( + image.classList.contains('loaded'), + true, + 'the loaded medium photo is painted inside the card', + ); + assert.equal(card.querySelector('[role=status]'), null); + const style = readFileSync( + new URL('./views/panels/WildlifeSightingCard.vue', import.meta.url), + 'utf8', + ); + assert.match(style, /object-fit: contain/); + assert.match(style, /height: 180px/); + }); +}); + +test('missing, restricted, obscured and Acartia photos all use the same compact text card', async (context) => { + for (const [label, extra] of [ + ['missing', { photo: null }], + ['restricted', { photo: { ...row.photo, licence: 'cc-by-nd' } }], + [ + 'untrusted', + { photo: { ...row.photo, square: 'https://other.example/square.jpg' } }, + ], + ['uncredited', { photo: { ...row.photo, attribution: '' } }], + ['obscured', { obscured: true }], + [ + 'Acartia', + { + origin: 'acartia', + stableId: 'acartia:456', + provider: { name: 'Synthetic network' }, + link: 'https://acartia.io', + }, + ], + ]) + await context.test(label, async (context) => { + const f = fixture(context, { ...row, ...extra }); + await f.click(); + const card = f.cardElement(); + assert.equal(card.querySelector('img'), null); + assert.equal(card.querySelector('[role=status]'), null); + assert.equal(card.querySelector('h2').textContent, row.commonName); + assert.equal(card.querySelector('i').textContent, row.scientificName); + assert.equal(document.querySelector('dialog'), null); + if (extra.obscured) { + assert.doesNotMatch(card.textContent, /ago|Photographer/); + assert.ok( + card.textContent.includes('Location obscured to a 0.2° cell'), + ); + assert.equal( + card.querySelector('time').getAttribute('datetime'), + row.observedOn, + ); + } + if (extra.origin === 'acartia') { + assert.ok(card.textContent.includes('seen 1h ago')); + assert.ok(card.textContent.includes('Reported by Synthetic network')); + assert.ok( + card.textContent.includes('Acartia · CC BY 4.0 · Salish Sea'), + ); + assert.ok(card.querySelector('a[href="https://www.bewhalewise.org"]')); + assert.equal( + card.querySelector('a').getAttribute('aria-label'), + `Open ${row.commonName} on Acartia`, + ); + } + }); +}); + +test('an Acartia card labels only the Acartia site as Acartia', async (context) => { + for (const link of [ + 'https://provider.example/photos/synthetic.jpg', + 'https://acartia.io.attacker.example/', + 'https://acartia.io/', + ]) + await context.test(link, async (context) => { + const f = fixture(context, { + ...row, + origin: 'acartia', + stableId: 'acartia:456', + provider: { name: 'Synthetic network' }, + link, + }); + await f.click(); + const card = f.cardElement(); + const hrefs = [...card.querySelectorAll('a')].map((a) => + a.getAttribute('href'), + ); + assert.deepEqual(hrefs, [ + 'https://acartia.io/', + 'https://www.bewhalewise.org', + ]); + assert.equal( + card.querySelector('.wildlife-sighting-card-source').textContent, + 'Acartia ↗', + ); + }); +}); + +test('safe placements near each viewport edge exclude actual HUD, toolbar and rail rects', () => { + const obstacles = [ + rect(0, 0, 260, 160), + rect(450, 0, 300, 80), + rect(1050, 200, 150, 500), + rect(0, 760, 300, 140), + ]; + const safe = { bounds: rect(6, 6, 1188, 888), obstacles }; + for (const point of [ + { x: 7, y: 450 }, + { x: 1193, y: 750 }, + { x: 850, y: 7 }, + { x: 600, y: 893 }, + ]) { + const result = placeWildlifeSightingCard( + point, + { width: 258, height: 300 }, + safe, + ); + assert.ok(result); + assert.deepEqual(result.pan, { x: 0, y: 0 }); + assert.ok( + result.left >= safe.bounds.left && result.right <= safe.bounds.right, + ); + assert.ok( + result.top >= safe.bounds.top && result.bottom <= safe.bounds.bottom, + ); + assert.ok( + obstacles.every( + (r) => + !( + result.left < r.right && + result.right > r.left && + result.top < r.bottom && + result.bottom > r.top + ), + ), + ); + } +}); + +test('a selected dot flies to its true position and its card pans clear of chrome', async (context) => { + const f = fixture(context, row, { x: 100, y: 100 }); + const hud = f.obstacle('.hud-top-left', rect(0, 0, 260, 160)); + f.obstacle('#right-context-rail', rect(1040, 200, 160, 700)); + f.windowRef.matchMedia = () => ({ matches: true }); + await f.click(); + assert.equal(f.pans.length, 1); + assert.equal(f.pans[0].reduced, true); + assert.deepEqual( + f.pans[0].to, + { x: 100, y: 178 }, + 'nearest clear point follows the measured HUD bottom', + ); + const safe = readWildlifeCardSafeArea({ + documentRef: document, + viewer: f.viewer, + windowRef: f.windowRef, + }); + assert.equal( + safe.obstacles[0].bottom, + hud.getBoundingClientRect().bottom + 6, + ); + assert.equal(f.flights.length, 1); + assert.deepEqual(f.flights[0].position, f.state.positions.get(row.stableId)); + f.moveEnd.emit(); + await settle(); + assert.equal(f.pans.length, 1); + f.layer.clearSightingSelection(); + await settle(); + f.setPoint({ x: -20, y: 400 }); + await f.click(); + assert.equal(f.pans.length, 2); + assert.deepEqual(f.pans[1].to, { x: 18, y: 400 }); +}); + +test('the camera correction preserves altitude and orientation and honors reduced motion', () => { + const flights = []; + const camera = { + positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 250_000), + positionCartographic: { height: 250_000 }, + heading: 0.2, + pitch: -1.2, + roll: 0.1, + pickEllipsoid: (p) => Cesium.Cartesian3.fromDegrees(p.x / 100, p.y / 100), + flyTo: (value) => flights.push(value), + }; + const viewer = { + camera, + scene: { + canvas: { getBoundingClientRect: () => rect(0, 0, 1200, 900) }, + globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, + }, + }; + for (const reduced of [false, true]) { + assert.equal( + panWildlifeSighting(viewer, { x: 10, y: 10 }, { x: 30, y: 50 }, reduced), + true, + ); + const flight = flights.at(-1); + assert.ok( + Math.abs( + Cesium.Cartographic.fromCartesian(flight.destination).height - 250_000, + ) < 0.001, + ); + assert.deepEqual(flight.orientation, { + heading: 0.2, + pitch: -1.2, + roll: 0.1, + }); + assert.equal(flight.duration, reduced ? 0 : 0.22); + } +}); + +test('the browser click that follows a globe selection does not close the card it opened', async (context) => { + const f = fixture(context); + await f.click(); + assert.ok(f.cardElement(), 'the globe click opened the card'); + assert.equal( + document.activeElement, + f.cardElement(), + 'focus lands on the card, not as a ring on its close button', + ); + // Cesium selects on pointer-up; the DOM click event lands on the canvas + // afterwards and must not count as an outside click. + f.canvas.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await settle(); + assert.ok(f.cardElement(), 'the card stays open'); + assert.equal(f.clears(), 0); + document.body.click(); + await settle(); + assert.equal(f.cardElement(), null, 'a real outside click still closes it'); +}); + +test('one card at a time, Escape, outside clicks and close all clear the selection', async (context) => { + const f = fixture(context); + await f.click(); + const second = { + ...row, + stableId: 'inat:789', + commonName: 'Another synthetic bird', + }; + f.state.rowById.set(second.stableId, second); + await f.click('dot', second); + assert.equal( + document.querySelectorAll('[data-wildlife-sighting-card]').length, + 1, + ); + assert.equal( + f.cardElement().querySelector('h2').textContent, + second.commonName, + ); + for (const close of ['Escape', 'outside', 'button']) { + if (close === 'Escape') + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }), + ); + if (close === 'outside') document.body.click(); + if (close === 'button') f.cardElement().querySelector('button').click(); + await settle(); + assert.equal(f.cardElement(), null); + assert.equal(f.state.selectedId, null); + assert.deepEqual(f.overlays.get('wildlife-sightings-selected'), []); + assert.equal(document.activeElement, f.opener); + await f.click(); + } + assert.equal(f.clears(), 3); +}); + +test('camera movement repositions the same card and removes frame work at rest', async (context) => { + const f = fixture(context); + await f.click(); + const card = f.cardElement(); + const before = f.host().style.left; + f.moveStart.emit(); + assert.equal(f.preRender.size(), 1); + f.setPoint({ x: 800, y: 450 }); + f.preRender.emit(); + assert.equal(f.cardElement(), card); + assert.notEqual(f.host().style.left, before); + f.moveEnd.emit(); + assert.equal(f.preRender.size(), 0); + await f.publish({ ...row, commonName: 'Updated synthetic bird' }); + assert.equal(f.cardElement(), card); + assert.equal(card.querySelector('h2').textContent, 'Updated synthetic bird'); + f.state.rowById.delete(row.stableId); + await f.publish(null); + assert.equal( + f.cardElement(), + card, + 'a refreshed sample cannot close the selected card', + ); + f.layer.clearSightingSelection(); + await settle(); + assert.equal(f.cardElement(), null); +}); + +test('replacement photo loads cannot expose the previous image and failures retain the text card', async (context) => { + const f = fixture(context); + await f.click(); + const previous = f.cardElement().querySelector('img'); + let decoded; + previous.decode = () => + new Promise((resolve) => { + decoded = resolve; + }); + previous.dispatchEvent(new Event('load')); + await f.publish({ + ...row, + photo: { ...row.photo, square: square.replace('/456/', '/789/') }, + }); + const current = f.cardElement().querySelector('img'); + decoded(); + await settle(); + assert.equal(current.classList.contains('loaded'), false); + current.dispatchEvent(new Event('error')); + await settle(); + assert.equal( + f.cardElement().querySelector('[role=status]').textContent, + 'Photo unavailable', + ); + assert.equal(current.style.display, 'none'); + assert.equal(f.cardElement().querySelector('a').href, row.link); +}); + +test('teardown removes subscriptions, placement listeners and host, and ignores stale notifications', async (context) => { + const f = fixture(context); + await f.click(); + f.moveStart.emit(); + const stale = f.staleListener(); + f.card.destroy(); + assert.equal(f.cardElement(), null); + assert.equal(f.host(), null); + assert.equal(f.subscriptions.size, 0); + assert.equal(f.preRender.size(), 0); + stale(row); + await f.click(); + assert.equal(f.cardElement(), null); + assert.equal(document.activeElement, f.opener); +}); + +test('locale changes translate the card without replacing the image or credit', async (context) => { + const before = getLocale(); + context.after(() => setLocale(before)); + const f = fixture(context); + await f.click(); + const card = f.cardElement(); + const image = card.querySelector('img'); + for (const locale of LOCALES) { + setLocale(locale); + await settle(); + assert.equal(f.cardElement(), card); + assert.equal(card.querySelector('img'), image); + assert.equal( + card.querySelector('button').getAttribute('aria-label'), + t('Close sighting'), + ); + assert.ok(card.textContent.includes('© Synthetic Photographer · CC BY-SA')); + } +}); + +test('date-only and unsafe-link rows remain readable, and no sighting rail or lightbox is shipped', async (context) => { + const f = fixture(context, { + ...row, + observedAt: null, + photo: null, + link: 'javascript:alert(1)', + }); + await f.click(); + assert.equal(f.cardElement().querySelector('a'), null); + assert.equal( + f.cardElement().querySelector('time').getAttribute('datetime'), + row.observedOn, + ); + assert.equal(PANEL_RAIL_ITEMS['wildlife-sighting-panel'], undefined); + assert.equal(sheetForPanelId('wildlife-sighting-panel'), null); + const source = readFileSync( + new URL('./wildlifeSightingCard.js', import.meta.url), + 'utf8', + ); + assert.doesNotMatch( + source, + /Lightbox|showModal|requestAnimationFrame|setInterval/, + ); +}); + +test('WHALES cards retain compact whale guidance outside the observation action', async (context) => { + const f = fixture(context); + f.layer.getParams = () => ({ taxa: 'whales' }); + await f.click(); + const guidance = f + .cardElement() + .querySelector('a[href="https://www.bewhalewise.org"]'); + assert.equal(guidance.textContent, 'Be Whale Wise'); + assert.equal( + guidance.parentElement.closest('a'), + null, + 'the external guidance link never nests inside the observation action', + ); +}); + +test('opening during camera motion tracks the card and closing stops frame work immediately', async (context) => { + const f = fixture(context); + f.moveStart.emit(); + assert.equal(f.preRender.size(), 0); + await f.click(); + assert.equal(f.preRender.size(), 1); + f.layer.clearSightingSelection(); + await settle(); + assert.equal(f.preRender.size(), 0); +}); + +test('newly mounted chrome repositions the open card without camera events or idle text work', async (context) => { + const observers = []; + class MutationObserver { + constructor(callback) { + this.callback = callback; + this.targets = []; + observers.push(this); + } + observe(target, options) { + this.targets.push({ target, options }); + } + disconnect() { + this.targets = []; + } + } + const f = fixture(context, row, { x: 600, y: 450 }, { MutationObserver }); + await f.click(); + const structural = observers.find((observer) => + observer.targets.some( + ({ target, options }) => + target === document.body && options.childList && options.subtree, + ), + ); + assert.ok( + structural, + 'structural observation exists only while a card is open', + ); + const original = f.cardElement(); + const before = f.host().style.top; + const hudWrapper = document.body.appendChild(document.createElement('div')); + const hud = hudWrapper.appendChild(document.createElement('div')); + hud.className = 'hud-top-left'; + hud.getBoundingClientRect = () => rect(500, 350, 200, 200); + structural.callback([{ addedNodes: [hudWrapper], removedNodes: [] }]); + await settle(); + assert.equal( + f.pans.length, + 1, + 'chrome newly covering the dot triggers one minimal pan', + ); + assert.equal(f.cardElement(), original); + assert.notEqual(f.host().style.top, before); + assert.equal(f.preRender.size(), 0); + const placed = rect( + Number.parseFloat(f.host().style.left), + Number.parseFloat(f.host().style.top), + 258, + 300, + ); + assert.ok( + placed.right <= 494 || + placed.left >= 706 || + placed.bottom <= 344 || + placed.top >= 556, + 'the card clears the measured HUD rect', + ); + const afterChrome = f.projections(); + // The observer ignores text nodes, ordinary clock wrappers and the card's + // own image/caption patches even though body observation sees their records. + const clock = hud.appendChild(document.createElement('span')); + const text = clock.appendChild(document.createTextNode('13:00:01')); + structural.callback([ + { + addedNodes: [clock, text, f.cardElement().querySelector('img')], + removedNodes: [], + }, + ]); + assert.equal(f.projections(), afterChrome); + f.layer.clearSightingSelection(); + await settle(); + assert.equal( + structural.targets.length, + 0, + 'closing disconnects structural observation', + ); +}); diff --git a/src/ui/wildlifeSightingPlacement.js b/src/ui/wildlifeSightingPlacement.js new file mode 100644 index 000000000..e638b9ccb --- /dev/null +++ b/src/ui/wildlifeSightingPlacement.js @@ -0,0 +1,274 @@ +import * as Cesium from 'cesium'; +import { WORLD_OVERLAY_OCCLUDER_SELECTORS } from '../overlays/worldOverlayChrome.js'; + +// DOM cards also avoid the HUD edges and responsive sheet containers. +export const WILDLIFE_CARD_CHROME_SELECTORS = Object.freeze([ + ...WORLD_OVERLAY_OCCLUDER_SELECTORS, + '.hud-left-edge', + '.hud-right-edge', + '#right-panel-stack', + '.gev-panel-rail', + '#location-bar', + '#control-panel', + '#param-slider-panel', + '#cockpit-hud .cockpit-topline', + '[data-mobile-sheet]', +]); +const GAP = 12; +const EDGE = 6; +export const WILDLIFE_SELECTED_CARD_WIDTH = 258; + +const overlaps = (a, b) => + a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; +const contains = (r, p) => + p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom; +const clamp = (value, low, high) => Math.max(low, Math.min(high, value)); +const rectAt = (left, top, width, height) => ({ + left, + top, + right: left + width, + bottom: top + height, + width, + height, +}); + +/** Read visible chrome rectangles inside the viewer's usable screen area. */ +export function readWildlifeCardSafeArea({ + documentRef, + viewer, + windowRef = globalThis.window, +}) { + const canvasRect = viewer?.scene?.canvas?.getBoundingClientRect?.(); + const width = + windowRef?.innerWidth || documentRef.documentElement.clientWidth; + const height = + windowRef?.innerHeight || documentRef.documentElement.clientHeight; + const left = Math.max(0, canvasRect?.left || 0) + EDGE; + const top = Math.max(0, canvasRect?.top || 0) + EDGE; + const right = + Math.min(width, canvasRect?.width > 0 ? canvasRect.right : width) - EDGE; + const bottom = + Math.min(height, canvasRect?.height > 0 ? canvasRect.bottom : height) - + EDGE; + const bounds = rectAt( + left, + top, + Math.max(0, right - left), + Math.max(0, bottom - top), + ); + const obstacles = []; + const elements = [ + ...documentRef.querySelectorAll(WILDLIFE_CARD_CHROME_SELECTORS.join(',')), + ]; + for (const element of elements) { + let hidden = false; + for ( + let node = element; + node && node !== documentRef.body; + node = node.parentElement + ) { + const style = windowRef?.getComputedStyle?.(node); + if ( + node.hidden || + style?.display === 'none' || + style?.visibility === 'hidden' || + style?.opacity === '0' + ) { + hidden = true; + break; + } + } + if (hidden) continue; + const rect = element.getBoundingClientRect(); + if (!(rect.width > 0 && rect.height > 0) || !overlaps(rect, bounds)) + continue; + obstacles.push( + rectAt( + rect.left - EDGE, + rect.top - EDGE, + rect.width + EDGE * 2, + rect.height + EDGE * 2, + ), + ); + } + return { bounds, obstacles, elements }; +} + +function nearestSafeAnchor(point, { bounds, obstacles }) { + const xs = [clamp(point.x, bounds.left + GAP, bounds.right - GAP)]; + const ys = [clamp(point.y, bounds.top + GAP, bounds.bottom - GAP)]; + for (const rect of obstacles) { + xs.push( + clamp(rect.left - GAP, bounds.left + GAP, bounds.right - GAP), + clamp(rect.right + GAP, bounds.left + GAP, bounds.right - GAP), + ); + ys.push( + clamp(rect.top - GAP, bounds.top + GAP, bounds.bottom - GAP), + clamp(rect.bottom + GAP, bounds.top + GAP, bounds.bottom - GAP), + ); + } + return ( + xs + .flatMap((x) => ys.map((y) => ({ x, y }))) + .filter( + (p) => contains(bounds, p) && !obstacles.some((r) => contains(r, p)), + ) + .sort( + (a, b) => + Math.hypot(a.x - point.x, a.y - point.y) - + Math.hypot(b.x - point.x, b.y - point.y), + )[0] || null + ); +} + +/** Four anchored placements, clamped along their free axis. Chrome is a set of + * actual DOM rectangles, so corner HUDs do not erase otherwise clear lanes. */ +export function placeWildlifeSightingCard(point, size, safeArea) { + const { bounds, obstacles } = safeArea; + const anchor = + contains(bounds, point) && !obstacles.some((r) => contains(r, point)) + ? point + : nearestSafeAnchor(point, safeArea); + if (!anchor) return null; + const width = Math.min(size.width, bounds.width); + const height = Math.min(size.height, bounds.height); + const centeredX = clamp( + anchor.x - width / 2, + bounds.left, + bounds.right - width, + ); + const centeredY = clamp( + anchor.y - height / 2, + bounds.top, + bounds.bottom - height, + ); + const candidates = [ + { + placement: 'above', + ...rectAt(centeredX, anchor.y - GAP - height, width, height), + }, + { placement: 'below', ...rectAt(centeredX, anchor.y + GAP, width, height) }, + { placement: 'right', ...rectAt(anchor.x + GAP, centeredY, width, height) }, + { + placement: 'left', + ...rectAt(anchor.x - GAP - width, centeredY, width, height), + }, + ]; + const fits = (rect) => + rect.left >= bounds.left && + rect.right <= bounds.right && + rect.top >= bounds.top && + rect.bottom <= bounds.bottom && + !obstacles.some((r) => overlaps(rect, r)); + let chosen = candidates.find(fits); + if (!chosen) { + // Crowded chrome may require sliding a side placement farther from its + // anchor. Search only obstacle/bounds edges; no pixels or idle frame loop. + const xs = [bounds.left, bounds.right - width, centeredX]; + const ys = [bounds.top, bounds.bottom - height, centeredY]; + for (const r of obstacles) { + xs.push(r.left - width, r.right); + ys.push(r.top - height, r.bottom); + } + const alternatives = xs + .flatMap((x) => + ys.map((y) => ({ + placement: + y + height <= anchor.y + ? 'above' + : y >= anchor.y + ? 'below' + : x >= anchor.x + ? 'right' + : 'left', + ...rectAt(x, y, width, height), + })), + ) + .filter((r) => fits(r) && !contains(r, anchor)); + alternatives.sort( + (a, b) => + Math.hypot( + clamp(anchor.x, a.left, a.right) - anchor.x, + clamp(anchor.y, a.top, a.bottom) - anchor.y, + ) - + Math.hypot( + clamp(anchor.x, b.left, b.right) - anchor.x, + clamp(anchor.y, b.top, b.bottom) - anchor.y, + ), + ); + chosen = alternatives[0]; + } + return chosen + ? { + ...chosen, + anchor, + pan: { x: anchor.x - point.x, y: anchor.y - point.y }, + } + : null; +} + +/** Project a front-side sighting into viewport coordinates. */ +export function projectWildlifeSighting(viewer, row, layer) { + if ( + !viewer?.scene || + !Number.isFinite(row?.lat) || + !Number.isFinite(row?.lon) + ) + return null; + const position = + layer?.getSightingPosition?.(row.stableId) || + Cesium.Cartesian3.fromDegrees(row.lon, row.lat); + const ellipsoid = viewer.scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84; + if ( + viewer.camera?.positionWC && + new Cesium.EllipsoidalOccluder( + ellipsoid, + viewer.camera.positionWC, + ).isPointVisible(position) !== true + ) + return null; + const point = Cesium.SceneTransforms.worldToWindowCoordinates( + viewer.scene, + position, + ); + if (!point) return null; + const rect = viewer.scene.canvas?.getBoundingClientRect?.(); + return { x: point.x + (rect?.left || 0), y: point.y + (rect?.top || 0) }; +} + +/** Translate the camera by the smallest screen correction and preserve its + * altitude/orientation. A card must never zoom merely to reveal its anchor. */ +export function panWildlifeSighting(viewer, from, to, reducedMotion = false) { + const camera = viewer?.camera; + if (!camera?.pickEllipsoid || !camera.flyTo) return false; + const rect = viewer.scene.canvas.getBoundingClientRect(); + const local = (p) => new Cesium.Cartesian2(p.x - rect.left, p.y - rect.top); + const ellipsoid = viewer.scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84; + const start = camera.pickEllipsoid(local(from), ellipsoid); + const finish = camera.pickEllipsoid(local(to), ellipsoid); + if (!start || !finish || !camera.positionWC) return false; + const delta = Cesium.Cartesian3.subtract( + start, + finish, + new Cesium.Cartesian3(), + ); + const destination = Cesium.Cartesian3.add( + camera.positionWC, + delta, + new Cesium.Cartesian3(), + ); + const cartographic = ellipsoid.cartesianToCartographic(destination); + if (!cartographic) return false; + cartographic.height = camera.positionCartographic.height; + camera.flyTo({ + destination: ellipsoid.cartographicToCartesian(cartographic), + orientation: { + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + }, + duration: reducedMotion ? 0 : 0.22, + easingFunction: Cesium.EasingFunction.QUADRATIC_OUT, + }); + return true; +} diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index 47f8fcdbe..ef122985f 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -753,6 +753,7 @@ const schemas = [ type: 'string', enum: ['once', 'continuous'], }, + pitchDeg: { type: 'number' }, }, required: ['motion'], }, diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 5c84259ad..296ae0251 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -55,7 +55,10 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // 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', + // control_cockpit states the own-layer entry rule (Contacts not required). + // move_camera tilt takes an optional pitchDeg angle, described as + // absolute with its -45 default, with steps sent as a direction. + '6a0d154e68beb787c4e20f12d29a5c86577dc343f3e71d8b5651998ea45d8c3c', ); }); @@ -174,6 +177,8 @@ test('all legacy action arguments are byte-identical after removing the delibera 'stop', ]); motion.enum = motion.enum.filter((value) => value !== 'rotate_globe'); + // Tilt to an angle is additive. + delete property('move_camera').pitchDeg; const panels = property('set_panel_open').panelId; panels.enum = panels.enum.filter( (id) => @@ -319,3 +324,15 @@ test('direct voice timeout schema accepts None and exact integers, rejects malfo for (const minutes of [-1, 61, 1.5, '5']) assert.ok(validateArgs(tool.parameters, { minutes }).length > 0); }); + +test('tilt accepts an angle with no direction', () => { + const schema = GEV_ACTION_SCHEMAS.find( + (tool) => tool.name === 'move_camera', + ).parameters; + assert.deepEqual(validateArgs(schema, { motion: 'tilt', pitchDeg: -45 }), []); + assert.deepEqual(validateArgs(schema, { motion: 'tilt' }), []); + // No range in the schema (prompt budget): the runner clamps to -89..-5. + assert.ok( + validateArgs(schema, { motion: 'tilt', pitchDeg: 'steep' }).length > 0, + ); +}); diff --git a/src/voice/chatControl.js b/src/voice/chatControl.js index 6ab5bf494..0525b9bc5 100644 --- a/src/voice/chatControl.js +++ b/src/voice/chatControl.js @@ -1,6 +1,7 @@ import { bindChatAttachments } from './chatAttachments.js'; import { bindChatPreferences } from './chatPreferences.js'; import { readStoredCloudVoiceAuthMode } from './cloudVoiceAuth.js'; +import { isStoppable } from './session.js'; import { t } from '../i18n/index.js'; /** @@ -94,7 +95,9 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { : ''; function open({ focus = true } = {}) { leaveChatSettings(); - if (!isTyped() && session.isActive()) session.stop(); + // Chat replaces voice, a stale voice error included, so the dock stops + // showing it. + if (!isTyped() && isStoppable(session)) session.stop(); onOpen?.(); ui.update('chat', { open: true, minimized: false, rows: 2, authNote }); sync(); @@ -389,15 +392,7 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { } const frame = ui.element('chatPanel')?.getBoundingClientRect?.(); close(); - ui.update('voicePanel', { - context: 'chat', - ...(frame?.width && { - frameWidth: frame.width, - frameHeight: frame.height, - }), - }); - ui.update('chat', { backVisible: true }); - sync(); + showChatSettings(frame); void ui.focus(['chatBack']); }), // The panel clears its context as it closes, so chat tracks its own. @@ -413,10 +408,37 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { }), ]; sync(); + // Settings shown from chat, in chat's frame when it has one. + function showChatSettings(frame) { + ui.update('voicePanel', { + context: 'chat', + ...(frame?.width && { + frameWidth: frame.width, + frameHeight: frame.height, + }), + }); + ui.update('chat', { backVisible: true }); + sync(); + } + // What a provider switch carries to the next mount: chat's Settings, never + // the conversation. The provider choice lives only in Settings, so chat + // itself is never open during a switch. + function presentation() { + if (!inChatSettings()) return null; + const { frameWidth: width, frameHeight: height } = ui.state.voicePanel; + return { view: 'settings', frame: width ? { width, height } : null }; + } + // Restored without moving focus: the switch keeps it on the provider choice. + function restorePresentation(state) { + if (disposed || state?.view !== 'settings') return; + showChatSettings(state.frame); + } return { close, minimize, restore: open, + presentation, + restorePresentation, get minimized() { return chat().open && chat().minimized; }, diff --git a/src/voice/chatControlView.test.mjs b/src/voice/chatControlView.test.mjs index 5dee1ad97..c0175456c 100644 --- a/src/voice/chatControlView.test.mjs +++ b/src/voice/chatControlView.test.mjs @@ -364,3 +364,35 @@ test('the chat gear is named for the settings it opens', async (t) => { 'Settings', ); }); + +test('a provider switch carries chat Settings to the next mount', async (t) => { + const mount = () => { + const ui = createTestVoiceUi({ panel: true }); + const chat = bindChatControl({ ui, session: typedSession() }); + t.after(() => chat.destroy()); + return { ui, chat }; + }; + // Chat's Settings: the next mount shows Settings in chat's frame again. + const before = mount(); + before.ui.emit('chat-button'); + before.ui.update('voicePanel', { hidden: false }); + before.ui.emit('settings-button'); + before.ui.update('voicePanel', { frameWidth: 320, frameHeight: 480 }); + const settings = before.chat.presentation(); + assert.deepEqual(settings, { + view: 'settings', + frame: { width: 320, height: 480 }, + }); + const after = mount(); + after.chat.restorePresentation(settings); + assert.equal(after.ui.state.voicePanel.context, 'chat'); + assert.equal(after.ui.state.voicePanel.frameWidth, 320); + assert.equal(after.ui.state.voicePanel.frameHeight, 480); + assert.equal(after.ui.state.chat.backVisible, true); + assert.equal(after.ui.state.root.assistantMode, 'chat'); + // Chat itself, or no chat at all, carries nothing. + const open = mount(); + open.ui.emit('chat-button'); + assert.equal(open.chat.presentation(), null); + assert.equal(mount().chat.presentation(), null); +}); diff --git a/src/voice/geminiSession.js b/src/voice/geminiSession.js index da93b4bb6..8996774b3 100644 --- a/src/voice/geminiSession.js +++ b/src/voice/geminiSession.js @@ -184,6 +184,9 @@ export class GeminiSessionController { this.model = null; this.epoch = 0; this.turn = 0; + // Identifies one turn's tool calls to the runner as one reply; a new + // object each turn, so no two sessions or turns share it. + this.turnReply = {}; this.globeToolsBlocked = false; this.globeResetFailed = false; this.inputGeneration = 0; @@ -971,6 +974,7 @@ export class GeminiSessionController { const tools = [...this.tools.values()]; this.turnAbort = new AbortController(); this.turn++; + this.turnReply = {}; this.typedIntent = null; this.radioStrongerCommit = null; this.pruneControls(); @@ -1544,6 +1548,7 @@ export class GeminiSessionController { ) throw new Error(msg('Gemini requested too many simultaneous actions')); const turn = this.turn; + const reply = this.turnReply; this.userTurnPending = false; this.modelComplete = false; // Admit each valid native call ID once per connection. @@ -1565,6 +1570,7 @@ export class GeminiSessionController { const action = new AbortController(); action.call = { id: call.id, name: call.name }; action.sequence = ++this.toolSequence; + action.reply = reply; this.tools.set(call.id, action); const priorTools = this.toolQueue; const args = @@ -1674,6 +1680,9 @@ export class GeminiSessionController { this.runAction(call.name, args, { signal: toolSignal, callId: call.id, + // Calls run one by one, so a motion can follow a fly-to of the + // same reply that already found nothing. + reply: action.reply, progress: (update) => { if (actionCurrent()) this.narration.progress(call.id, update); }, diff --git a/src/voice/geminiSession.test.mjs b/src/voice/geminiSession.test.mjs index 9ce8d351a..82f403ef7 100644 --- a/src/voice/geminiSession.test.mjs +++ b/src/voice/geminiSession.test.mjs @@ -1241,6 +1241,34 @@ test('successful stronger Radio stop still invalidates the prepared group', asyn h.c.stop(); }); +test('Gemini tools of one turn share a reply identity, and a new turn gets another', async () => { + const replies = []; + const h = harness({ + runAction: (name, _args, next) => { + replies.push([name, next.reply]); + return { ok: true, action: name }; + }, + }); + await h.c.start({ pushToTalk: true }); + h.c.sendTextCommand('Fly to Xyzzyville and pan left'); + const ws = h.sockets[0]; + requestTool(ws, 'fly', 'fly_to_location', { query: 'Xyzzyville' }); + requestTool(ws, 'pan', 'move_camera', { motion: 'pan', direction: 'left' }); + await h.c.toolQueue; + assert.equal(replies.length, 2); + assert.ok(replies[0][1], 'a sequentially run call carries its reply'); + assert.equal(replies[1][1], replies[0][1]); + drainRadioReply(h); + await tick(); + assert.equal(h.c.sendTextCommand('Pan left'), true); + requestTool(ws, 'pan-2', 'move_camera', { motion: 'pan', direction: 'left' }); + await h.c.toolQueue; + assert.equal(replies.length, 3); + assert.ok(replies[2][1]); + assert.notEqual(replies[2][1], replies[0][1]); + h.c.stop(); +}); + test('slow Gemini tools reuse shared progress timing without injecting a conversation turn', async () => { const pending = deferred(); let options; diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 53208c538..9692edf94 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -47,6 +47,9 @@ import { flyRoute, interruptCameraMotion, adjustOrbitRange, + manualCameraInputCount, + moveCameraArgsError, + getActiveCameraMotion, } from '../cameraVerbs.js'; import * as defaultFloorServices from '../data/groundFloor.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; @@ -286,6 +289,28 @@ export function createGevActionRunner({ ? { ...SPEECH_BUILDERS, ...speechBuilders } : SPEECH_BUILDERS; const _layerEnabledAt = new Map(); + // The latest voice flight. A tilt, orbit, pan or rotate in the same reply + // as "fly to X" waits for it to land: run at once, its navigation + // cancelled the flight, or the flight (still geocoding) then replaced the + // motion. The wait follows later fly-tos (voiceFlightTarget). + let voiceFlight = null; + // Waiters to tell when a newer voice flight begins. + const voiceFlightWatchers = new Set(); + // Camera handoffs (navigation stamps) seen, and how many of them voice + // fly-tos made. A search, share link or import that stamps during a flight + // leaves it flying; the motion queued behind it then yields to that one. + // The voice fly-tos of the latest reply that sent one: whether any moved + // the camera, and whether one found nothing. A motion of that reply run + // after its fly-to found nothing (Gemini runs a reply's calls one by one) + // has no flight to wait on, and must not move the old view instead. + let replyFlights = null; + let cameraHandoffs = 0; + let voiceFlightHandoffs = 0; + const otherHandoffs = () => cameraHandoffs - voiceFlightHandoffs; + const unsubscribeCameraHandoff = + styleManager?.subscribeCameraHandoff?.(() => { + cameraHandoffs += 1; + }) ?? null; let analystEngine; // Area handles live for this runner (the page): resolve_area, the // analyst's region/area/drawn scopes, find_imagery and osm_query all read @@ -427,13 +452,30 @@ export function createGevActionRunner({ const zoomOut = String(args.direction || '').toLowerCase() === 'out'; const amt = String(args.amount || 'medium').toLowerCase(); const factor = { little: 1.25, medium: 1.6, lot: 2.4 }[amt] || 1.6; - if (adjustOrbitRange(zoomOut ? factor : 1 / factor)) { + const held = adjustOrbitRange(zoomOut ? factor : 1 / factor); + if (held) { + const direction = zoomOut ? 'out' : 'in'; + if (!held.changed) { + return { + ok: false, + action: 'adjust_camera_zoom', + direction, + amount: amt, + error: `Not zoomed: the ${held.kind} is already at its ${zoomOut ? 'farthest' : 'closest'} range.`, + }; + } return { ok: true, action: 'adjust_camera_zoom', - direction: zoomOut ? 'out' : 'in', + direction, amount: amt, - orbitRadiusAdjusted: true, + [held.kind === 'tilt' ? 'tiltRangeAdjusted' : 'orbitRadiusAdjusted']: + true, + // A tilt to an angle names the angle the new range now ends it at. + ...(held.pitchDeg !== undefined && { + pitchDeg: held.pitchDeg, + note: held.note, + }), }; } } @@ -1125,34 +1167,104 @@ export function createGevActionRunner({ runOptions, ); } - return flyToRequestedLocation(viewer, args, { + // Chained fly-tos: a grab during an earlier one still flying counts. + // The flight replaced is the last one that moved, past lookups that + // found nothing. + const prior = voiceFlightTarget(voiceFlight); + const flight = trackVoiceFlight( + prior && !prior.done ? prior.inputCount : undefined, + prior, + ); + // Navigation by anything but a voice fly-to since this one began. + flight.handoffs = otherHandoffs(); + voiceFlight = flight; + const reply = voiceReplyOf(runOptions); + if (reply !== null && replyFlights?.reply !== reply) + replyFlights = { reply, moved: false, foundNothing: false }; + const replyRecord = reply !== null ? replyFlights : null; + for (const notify of [...voiceFlightWatchers]) notify(); + // A motion already running (one the model sent before this fly-to) is + // stopped when the flight takes navigation; the result says so. + let stoppedMotion = null; + const noteStop = + (hook) => + (...hookArgs) => { + const before = getActiveCameraMotion(); + const handoffsBefore = cameraHandoffs; + try { + return hook(...hookArgs); + } finally { + // A voice fly-to's own stamps are not another navigation, even + // for the flight it replaces (a later lookup may find nothing). + voiceFlightHandoffs += cameraHandoffs - handoffsBefore; + if ( + before && + !stoppedMotion && + getActiveCameraMotion()?.motionId !== before.motionId + ) + stoppedMotion = before.kind; + } + }; + const flown = flyToRequestedLocation(viewer, args, { + onFlightEnd: flight.settle, onProgress: reportProgress, placeSearch, searchNavigation, signal: runOptions.signal, runImmediate: typeof styleManager?.runImmediateLocationNavigation === 'function' - ? (navigate) => - styleManager.runImmediateLocationNavigation(navigate) + ? noteStop((navigate) => + styleManager.runImmediateLocationNavigation(navigate), + ) : null, beginDeferred: typeof styleManager?.beginDeferredLocationNavigation === 'function' - ? () => styleManager.beginDeferredLocationNavigation() + ? noteStop(() => styleManager.beginDeferredLocationNavigation()) : null, reassertDeferred: typeof styleManager?.reassertDeferredLocationNavigation === 'function' - ? (generation) => - styleManager.reassertDeferredLocationNavigation(generation) + ? noteStop((generation) => + styleManager.reassertDeferredLocationNavigation(generation), + ) : null, - onStart: () => { + onStart: noteStop(() => { if (typeof styleManager?.beginLocationNavigation === 'function') { styleManager.beginLocationNavigation(); return; } interruptCameraMotion('nav:fly_to_location'); if (viewer.trackedEntity) stopAllTracking(viewer, dataManager); - }, + }), }); + // No flight started: nothing to wait for. A lookup that was not found, + // threw, or was cancelled once a newer voice fly-to had begun (which + // takes navigation from it) moved nothing, so a queued motion looks + // past it to the flight it replaced. One cancelled while still the + // latest voice flight lost navigation to something else (a search, a + // share link, Cockpit): the motion then refuses. A flight its own hooks + // already settled (cancelled mid-air) keeps its status. + const notStarted = () => { + if (!flight.done) flight.movedNothing = true; + flight.settle('failed'); + }; + flown.then((result) => { + if (result?.ok === true) return; + if (result?.cancelled && voiceFlight === flight) + flight.settle('failed'); + else notStarted(); + }, notStarted); + // After the outcome above, which marks a lookup that moved nothing. + const noteReply = (result) => { + if (!replyRecord) return; + if (result?.ok === true) replyRecord.moved = true; + else if (flight.movedNothing) replyRecord.foundNothing = true; + }; + flown.then(noteReply, () => noteReply(null)); + return flown.then((result) => + stoppedMotion && result && typeof result === 'object' + ? { ...result, stoppedMotion } + : result, + ); } if (name === 'adjust_camera_zoom') { @@ -1254,6 +1366,85 @@ export function createGevActionRunner({ } if (name === 'move_camera') { + const motion = String(args.motion || '').toLowerCase(); + // A lookup that already found nothing does not hide the last flight + // that moved: running now would cancel that flight mid-air. + const pending = voiceFlightTarget(voiceFlight); + const reply = voiceReplyOf(runOptions); + if ( + VOICE_FLIGHT_WAITING_MOTIONS.has(motion) && + !(pending && !pending.done) && + reply !== null && + replyFlights?.reply === reply && + replyFlights.foundNothing && + !replyFlights.moved + ) + return { + ok: false, + action: 'move_camera', + motion, + error: `Not run: the fly-to in this reply found nothing, so the ${motion} was not run at the old view.`, + }; + if ( + VOICE_FLIGHT_WAITING_MOTIONS.has(motion) && + pending && + !pending.done + ) { + // Bad arguments are refused now, not after waiting on the flight. + const argsError = moveCameraArgsError(args); + if (argsError) return argsError; + const deadline = Date.now() + VOICE_FLIGHT_WAIT_MS; + const start = pending; + // Act on where the camera lands. Whatever a flight reports, a later + // fly-to replaces it, and the wait moves to that one as soon as it + // begins (a stalled geocode must not hold the motion past a newer + // flight that landed). A geocode that loses navigation reports + // failed, and a flight left flying while the next place is looked up + // can still land; running there would take navigation and cancel the + // newer one. A later lookup that found nothing moved nothing, so the + // motion looks past it to the last flight that did move the camera. + let flight = start; + let status; + for (;;) { + status = await waitForVoiceFlight( + flight, + runOptions.signal, + deadline, + voiceFlightWatchers, + ); + if (status === 'aborted' || status === 'timeout' || !current()) break; + const target = voiceFlightTarget(voiceFlight, start); + if (!target || (target === flight && status !== 'newer')) break; + flight = target; + } + if (!current()) + return { ok: false, action: 'move_camera', cancelled: true }; + if (status !== 'arrived' || flight.movedNothing) + return { + ok: false, + action: 'move_camera', + motion, + error: `Not run: the flight this ${motion} was waiting for did not arrive.`, + }; + // The flight lands even when the user grabs the globe mid-flight; + // the queued motion must not take the camera back from them. + if (manualCameraInputCount() !== flight.inputCount) + return { + ok: false, + action: 'move_camera', + motion, + error: `Not run: the user moved the camera during the flight, so the ${motion} was dropped.`, + }; + // A search, share link or import that took navigation during the + // flight leaves it flying; running now would cancel that navigation. + if (otherHandoffs() !== flight.handoffs) + return { + ok: false, + action: 'move_camera', + motion, + error: `Not run: another navigation took the camera during the flight, so the ${motion} was dropped.`, + }; + } return moveCamera(args, (navigate, releaseOptions) => runManagedVoiceNavigation( styleManager, @@ -1569,6 +1760,7 @@ export function createGevActionRunner({ * forget the analyst's "last answer". Area handles stay (page lifetime). */ runVoiceAction.resetConversation = () => { + replyFlights = null; conversationAbort.abort(); conversationAbort = new AbortController(); conversation++; @@ -1582,6 +1774,7 @@ export function createGevActionRunner({ if (disposed) return; runVoiceAction.resetConversation(); disposed = true; + unsubscribeCameraHandoff?.(); voiceAreas.dispose(); }; return runVoiceAction; @@ -2854,6 +3047,116 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) { ); } +let voiceFlightCount = 0; + +/** + * The model reply a voice call belongs to: Gemini's turn (`reply`), or the + * OpenAI response its sibling calls share (`authorityGroup`). Null when the + * caller names none. + */ +function voiceReplyOf(options) { + return options?.reply ?? options?.authorityGroup ?? null; +} + +/** + * A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. + * `replaced` is the last flight that moved when this one began (past lookups + * that found nothing), landed or not. It is kept only while this one may turn + * out to have moved nothing (a lookup that found no place), so a queued + * motion can look past it to that flight and its outcome. + */ +function trackVoiceFlight( + inputCount = manualCameraInputCount(), + replaced = null, +) { + const flight = { + done: false, + seq: (voiceFlightCount += 1), + replaced, + movedNothing: false, + // Manual input after this means the user took the camera mid-flight. + inputCount, + status: null, + // Waiters to tell when this flight ends. + watchers: new Set(), + settle: (status) => { + if (flight.done) return; + flight.done = true; + flight.status = status; + // Kept only past a lookup that found nothing, and then straight to the + // flight that moved, so a run of such lookups holds no chain. + flight.replaced = flight.movedNothing + ? voiceFlightTarget(flight.replaced) + : null; + for (const notify of [...flight.watchers]) notify(status); + flight.watchers.clear(); + }, + }; + return flight; +} + +/** + * The flight a motion queued since `start` acts on: the latest one that + * actually started. A lookup that found nothing moved nothing, so look past + * it to the flight it replaced, but never to one older than `start` (a flight + * from an earlier reply). Null when every later lookup found nothing. With + * no `start`, the latest flight that started, back through lookups that + * found nothing. + */ +function voiceFlightTarget(latest, start = null) { + let flight = latest; + while (flight?.movedNothing) flight = flight.replaced; + return flight && (!start || flight.seq >= start.seq) ? flight : null; +} + +/** + * Longest a motion waits for its flight (geocode plus flight, which is about + * 2 s) before giving up. The wait also holds the tool output, and on Gemini + * the later tools in the turn. + */ +const VOICE_FLIGHT_WAIT_MS = 15_000; + +/** + * Motions that act on the place the camera ends up at, so one sent beside a + * fly-to waits for it. Stop is not one, and rotate_globe turns the + * whole Earth from Globe View rather than at a place. + */ +const VOICE_FLIGHT_WAITING_MOTIONS = new Set([ + 'tilt', + 'orbit', + 'pan', + 'rotate', +]); + +/** + * Wait for a voice flight to end, or for a newer one to begin ('newer'): + * 'timeout' or 'aborted' if neither happens. A flight that has ended answers + * at once. Every listener is removed when the wait ends, so a flight that + * never settles holds nothing. + */ +function waitForVoiceFlight(flight, signal, deadline, newerWatchers) { + if (signal?.aborted) return Promise.resolve('aborted'); + if (flight.done) return Promise.resolve(flight.status); + return new Promise((resolve) => { + const finish = (status) => { + clearTimeout(timer); + signal?.removeEventListener?.('abort', onAbort); + flight.watchers.delete(finish); + newerWatchers?.delete(onNewer); + resolve(status); + }; + const onAbort = () => finish('aborted'); + const onNewer = () => finish('newer'); + const timer = setTimeout( + () => finish('timeout'), + Math.max(0, deadline - Date.now()), + ); + signal?.addEventListener?.('abort', onAbort, { once: true }); + flight.watchers.add(finish); + newerWatchers?.add(onNewer); + }); +} + /** Run one validated voice camera mutation through the UI-owned authority seam. */ function runManagedVoiceNavigation( styleManager, @@ -3417,6 +3720,7 @@ async function flyToRequestedLocation( runImmediate = null, beginDeferred = null, reassertDeferred = null, + onFlightEnd = null, } = {}, ) { const requestedRangeM = Number(args.rangeM); @@ -3440,12 +3744,19 @@ async function flyToRequestedLocation( settleArrival = resolve; }) : null; - const arrivalHooks = arrival - ? { - onComplete: () => settleArrival?.('arrived'), - onCancel: () => settleArrival?.('cancelled'), - } - : {}; + const arrivalHooks = + arrival || onFlightEnd + ? { + onComplete: () => { + settleArrival?.('arrived'); + onFlightEnd?.('arrived'); + }, + onCancel: () => { + settleArrival?.('cancelled'); + onFlightEnd?.('cancelled'); + }, + } + : {}; const afterArrival = async (result, label) => { if (!arrival || result?.ok !== true) return result; onProgress('fly', label); diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 44073d60b..f4be07148 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -143,6 +143,14 @@ test('track_entity runner narrates a callsign-less aircraft by its registration' function createVoiceNavigationHarness({ cockpitActive = false } = {}) { const order = []; let generation = 0; + // Camera handoffs, published on every navigation stamp as the shell does. + const handoffListeners = new Set(); + const stamp = (noun) => { + generation += 1; + order.push(`stamp:${noun}`); + for (const listener of [...handoffListeners]) listener({ generation }); + return generation; + }; const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500); const viewer = { trackedEntity: { id: 'prior-aircraft' }, @@ -179,11 +187,7 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) { return runExplicitNavigation({ cockpitActive, noun, - stamp: () => { - generation += 1; - order.push(`stamp:${noun}`); - return generation; - }, + stamp: () => stamp(noun), release: () => { order.push('release'); viewer.trackedEntity = undefined; @@ -195,12 +199,21 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) { }); }, getDetectionState: () => ({ detectionMode: 'DENSE' }), + subscribeCameraHandoff(listener) { + handoffListeners.add(listener); + return () => handoffListeners.delete(listener); + }, + // A voice lookup stamps when it begins and leaves any flight flying. + beginDeferredLocationNavigation: () => stamp('location-lookup'), + reassertDeferredLocationNavigation: (stamped) => stamped === generation, }; return { order, viewer, styleManager, currentGeneration: () => generation, + // A location-panel search: a deferred stamp that leaves a flight flying. + panelSearch: () => stamp('panel-search'), }; } @@ -276,6 +289,841 @@ test('dependent voice navigation waits for the destination viewport to arrive', assert.equal(result.arrived, true); }); +// "fly to London and tilt the camera" arrives as two sibling calls. +// Run at once, the tilt's navigation cancelled the flight; with a geocoded +// place, the late flight replaced the tilt. +function tiltAfterFlightHarness() { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const harness = createVoiceNavigationHarness(); + const { viewer, styleManager } = harness; + const flights = []; + viewer.camera.flyTo = (options) => { + harness.order.push('fly'); + flights.push(options); + }; + styleManager.runImmediateLocationNavigation = (navigate) => ( + styleManager.runImmediateNavigation('location', navigate) + ); + return { ...harness, flights }; +} + +const settledYet = (promise) => { + let settled = false; + promise.then(() => { settled = true; }); + return () => settled; +}; + +test('a tilt in the same reply as a fly-to waits for the flight to land', async () => { + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + const fly = await runner('fly_to_location', { locationId: 'austin' }); + assert.equal(fly.ok, true); + const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const tiltDone = settledYet(tilt); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(tiltDone(), false, 'the tilt must wait while the camera flies'); + assert.equal( + order.slice(order.indexOf('fly')).includes('cancel'), + false, + 'the tilt must not cancel the flight', + ); + flights[0].complete(); + const result = await tilt; + assert.equal(result.ok, true); + assert.equal(result.motion, 'tilt'); +}); + +test('a tilt waits through a geocoded fly-to search, then for its flight', async () => { + const { viewer, styleManager } = tiltAfterFlightHarness(); + let finishSearch; + let landFlight; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + finishSearch = () => { + landFlight = options.onComplete; + resolve({ label: query, navigationMode: 'city-overview' }); + }; + }), + }); + const fly = runner('fly_to_location', { query: 'Somewhere Far' }); + const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const tiltDone = settledYet(tilt); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(tiltDone(), false, 'the tilt must wait for the place lookup'); + finishSearch(); + assert.equal((await fly).ok, true); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(tiltDone(), false, 'the tilt must wait for the flight too'); + landFlight(); + assert.equal((await tilt).ok, true); +}); + +test('a tilt waiting on a flight that never lands is not run', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + await runner('fly_to_location', { locationId: 'austin' }); + const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + flights[0].cancel(); + const result = await tilt; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); +}); + +test('a tilt beside a fly-to that found nothing is not run', async () => { + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + const fly = runner('fly_to_location', { query: 'Nowhere' }); + const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + assert.equal((await fly).ok, false); + // Nothing flew: the tilt answers at once and honestly, never at the old view. + const result = await tilt; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); + // A later, separate tilt is not held by the failed lookup. + assert.equal((await runner('move_camera', { motion: 'tilt', pitchDeg: -45 })).ok, true); +}); + +test('a tilt with no voice flight pending runs at once', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + await runner('fly_to_location', { locationId: 'austin' }); + flights[0].complete(); + const result = await runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + assert.equal(result.ok, true); +}); + +// Orbit, pan and rotate also act at the place the camera ends up, so "fly to +// Paris and orbit it" holds the orbit the same way. +for (const motion of [ + { motion: 'orbit' }, + { motion: 'pan', direction: 'left' }, + { motion: 'rotate', direction: 'right' }, +]) { + test(`${motion.motion} in the same reply as a fly-to waits for the flight to land`, async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', motion); + const movedDone = settledYet(moved); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, `the ${motion.motion} must wait while the camera flies`); + assert.equal( + order.slice(order.indexOf('fly')).includes('cancel'), + false, + `the ${motion.motion} must not cancel the flight`, + ); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, motion.motion); + }); + + test(`${motion.motion} waits through a geocoded fly-to search, then for its flight`, async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager } = tiltAfterFlightHarness(); + let finishSearch; + let landFlight; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + finishSearch = () => { + landFlight = options.onComplete; + resolve({ label: query, navigationMode: 'city-overview' }); + }; + }), + }); + const fly = runner('fly_to_location', { query: 'Somewhere Far' }); + const moved = runner('move_camera', motion); + const movedDone = settledYet(moved); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, `the ${motion.motion} must wait for the place lookup`); + assert.equal(getActiveCameraMotion(), null, 'nothing moves before the flight'); + finishSearch(); + assert.equal((await fly).ok, true); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, `the ${motion.motion} must wait for the flight too`); + landFlight(); + assert.equal((await moved).ok, true); + assert.equal(getActiveCameraMotion()?.kind, motion.motion); + }); + + test(`${motion.motion} waiting on a flight that never lands is not run`, async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + await runner('fly_to_location', { locationId: 'austin' }); + const moved = runner('move_camera', motion); + flights[0].cancel(); + const result = await moved; + assert.equal(result.ok, false); + assert.equal(result.motion, motion.motion); + assert.match(result.error, new RegExp(`this ${motion.motion} was waiting for did not arrive`)); + assert.equal(getActiveCameraMotion(), null); + }); +} + +// Cesium lands a flight even when the user grabs the globe mid-flight, so the +// motion queued behind it must not then take the camera back. +for (const motion of [ + { motion: 'orbit' }, + { motion: 'tilt', pitchDeg: -45 }, +]) { + test(`${motion.motion} queued behind a fly-to is dropped when the user drags mid-flight`, async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const canvasListeners = new Map(); + viewer.scene.canvas.addEventListener = (type, listener) => { + canvasListeners.set(type, listener); + }; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', motion); + canvasListeners.get('pointerdown')(); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, false); + assert.equal(result.motion, motion.motion); + assert.match(result.error, /moved the camera during the flight/); + assert.equal(getActiveCameraMotion(), null); + }); +} + +test('a drag during the first of two chained fly-tos still drops the queued motion', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const canvasListeners = new Map(); + viewer.scene.canvas.addEventListener = (type, listener) => { + canvasListeners.set(type, listener); + }; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + canvasListeners.get('touchstart')(); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + flights.at(-1).complete(); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /moved the camera during the flight/); +}); + +test('input before a fly-to does not drop the motion queued behind it', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const canvasListeners = new Map(); + viewer.scene.canvas.addEventListener = (type, listener) => { + canvasListeners.set(type, listener); + }; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + canvasListeners.get('wheel')(); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + flights[0].complete(); + assert.equal((await moved).ok, true); +}); + +test('a motion follows a later fly-to in the same reply that cancels its flight', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + const movedDone = settledYet(moved); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + // The second flight supersedes the first, as Cesium's flyTo does. + flights[0].cancel(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the orbit must wait for the second flight'); + flights[1].complete(); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, 'orbit'); +}); + +test('a motion follows a later fly-to whose start cancels its geocode', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + const flyA = runner('fly_to_location', { query: 'Place A' }); + const moved = runner('move_camera', { motion: 'orbit' }); + const movedDone = settledYet(moved); + const flyB = runner('fly_to_location', { query: 'Place B' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // B takes navigation authority, so A's lookup comes back cancelled. + searches[0].resolve({ cancelled: true }); + assert.equal((await flyA).cancelled, true); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the orbit must follow B, not drop'); + searches[1].resolve({ label: 'Place B', navigationMode: 'city-overview' }); + assert.equal((await flyB).ok, true); + searches[1].options.onComplete(); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, 'orbit'); +}); + +test('a motion does not run at a flight that lands while the next place is looked up', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const movedDone = settledYet(moved); + const flyB = runner('fly_to_location', { query: 'Place B' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // Deferred navigation leaves A flying while B is looked up, and A lands. + const before = order.length; + flights[0].complete(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the tilt must wait for B'); + assert.equal( + order.slice(before).includes('cancel'), + false, + 'the tilt must not take navigation and cancel B', + ); + searches[0].resolve({ label: 'Place B', navigationMode: 'city-overview' }); + assert.equal((await flyB).ok, true); + searches[0].options.onComplete(); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, 'tilt'); +}); + +// A later fly-to that finds nothing moves nothing: the motion still runs where +// the earlier flight lands, whether the lookup fails before or after that. +for (const order of ['lands first', 'not found first']) { + test(`a motion runs at its flight when a later fly-to finds nothing (${order})`, async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const harness = tiltAfterFlightHarness(); + const { viewer, styleManager, flights } = harness; + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const flyB = runner('fly_to_location', { query: 'Nowhere' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + if (order === 'lands first') flights[0].complete(); + searches[0].resolve(null); + assert.equal((await flyB).ok, false); + if (order !== 'lands first') flights[0].complete(); + const result = await moved; + assert.equal(result.ok, true, result.error); + assert.equal(result.motion, 'tilt'); + }); +} + +test('a motion beside a fly-to that finds nothing never acts at an earlier reply\'s flight', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + // Reply 1: "fly to Austin", still flying. + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + // Reply 2: "fly to Nowhere and tilt". + const flyB = runner('fly_to_location', { query: 'Nowhere' }); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + await new Promise((resolve) => setTimeout(resolve, 0)); + searches[0].resolve(null); + assert.equal((await flyB).ok, false); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); +}); + +// Two later lookups that both find nothing, in either order, leave the motion +// acting where the first flight landed. +for (const first of [0, 1]) { + test(`a motion looks past two lookups that found nothing (lookup ${first + 1} fails first)`, async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + const flyB = runner('fly_to_location', { query: 'Nowhere B' }); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + searches[first].resolve(null); + searches[1 - first].resolve(null); + assert.equal((await flyB).ok, false); + assert.equal((await flyC).ok, false); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, true, result.error); + }); +} + +test('a lookup cancelled by a newer one that finds nothing does not hide the flight that landed', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const flyB = runner('fly_to_location', { query: 'Place B' }); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + flights[0].complete(); + // C's start takes navigation from B's lookup; C then finds nothing. + searches[0].resolve({ cancelled: true }); + searches[1].resolve(null); + assert.equal((await flyB).cancelled, true); + assert.equal((await flyC).ok, false); + const result = await moved; + assert.equal(result.ok, true, result.error); +}); + +test('a motion sent after a fly-to that found nothing still waits for the flight before it', async (t) => { + // Serial dispatch: "fly to Austin, fly to Nowhere, tilt", one call at a time. + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + assert.equal((await runner('fly_to_location', { query: 'Nowhere' })).ok, false); + const before = order.length; + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const movedDone = settledYet(moved); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the tilt must wait for the Austin flight'); + assert.equal(order.slice(before).includes('cancel'), false); + flights[0].complete(); + assert.equal((await moved).ok, true); +}); + +test('two lookups that found nothing in a row still leave the motion waiting for the flight', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + assert.equal((await runner('fly_to_location', { query: 'Nowhere' })).ok, false); + assert.equal((await runner('fly_to_location', { query: 'Nowhere 2' })).ok, false); + const before = order.length; + const moved = runner('move_camera', { motion: 'orbit' }); + const movedDone = settledYet(moved); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the orbit must wait for the Austin flight'); + assert.equal(order.slice(before).includes('cancel'), false); + flights[0].complete(); + assert.equal((await moved).ok, true); +}); + +// A flight that already settled is still what a later failed lookup replaced. +test('a motion acts at a landed flight that a later failed lookup replaced', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + const flyA = runner('fly_to_location', { query: 'Slow A' }); + const moved = runner('move_camera', { motion: 'orbit' }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + flights[0].complete(); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // B's start made A's lookup stale; C then finds nothing. + searches[0].resolve({ cancelled: true }); + searches[1].resolve(null); + assert.equal((await flyA).cancelled, true); + assert.equal((await flyC).ok, false); + const result = await moved; + assert.equal(result.ok, true, result.error); +}); + +test('a motion refuses when the flight a later failed lookup replaced was cancelled', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + const flyA = runner('fly_to_location', { query: 'Slow A' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // The motion sees Austin land, then follows A's lookup. + flights[0].complete(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + flights[1].cancel(); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + searches[0].resolve({ cancelled: true }); + searches[1].resolve(null); + assert.equal((await flyA).cancelled, true); + assert.equal((await flyC).ok, false); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); +}); + +test('a stale motion does not follow a newer reply\'s fly-to', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + let live = true; + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }, { isCurrent: () => live }); + // A newer reply takes over and flies elsewhere; this reply is stale. + live = false; + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + flights[0].cancel(); + // The newer flight never lands here: following it would hold this answer. + const result = await Promise.race([ + moved, + new Promise((resolve) => setTimeout(() => resolve('still waiting'), 50)), + ]); + assert.notEqual(result, 'still waiting'); + assert.equal(result.ok, false); + assert.equal(result.cancelled, true); +}); + +test('a motion gives up on a flight that has not landed in 15 s', async (t) => { + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); + const moved = runner('move_camera', { motion: 'orbit' }); + const movedDone = settledYet(moved); + await Promise.resolve(); + t.mock.timers.tick(14_999); + await Promise.resolve(); + assert.equal(movedDone(), false); + t.mock.timers.tick(1); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); +}); + +test('a motion follows a newer fly-to that lands while its own lookup stalls', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + // Place A's lookup never answers. + searchNavigation: () => new Promise(() => {}), + }); + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); + runner('fly_to_location', { query: 'Place A' }); + const moved = runner('move_camera', { motion: 'orbit' }); + const movedDone = settledYet(moved); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + t.mock.timers.tick(2_000); + await Promise.resolve(); + assert.equal(movedDone(), false, 'the orbit must wait for the newer flight to land'); + flights[0].complete(); + for (let i = 0; i < 5 && !movedDone(); i += 1) await Promise.resolve(); + assert.equal(movedDone(), true, 'the orbit must not wait out A'); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, 'orbit'); +}); + +// "Fly to Paris and orbit it", then a location-panel search while the camera +// flies: the search stamps a deferred navigation without stopping the flight. +// Run where the flight landed, the orbit took navigation and the search's +// reassert failed, so the search was silently dropped. +// Gemini runs one reply's calls one after another: by the time "pan left" +// runs, "fly to Xyzzyville" has already found nothing, so there is no flight +// to wait on, and the pan moved the old view with ok: true. +test('a motion run after its reply\'s fly-to found nothing is not run', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + const reply = { turn: 1 }; + const fly = await runner('fly_to_location', { query: 'Xyzzyville' }, { reply }); + assert.equal(fly.ok, false); + const pan = await runner( + 'move_camera', + { motion: 'pan', direction: 'left' }, + { reply }, + ); + assert.equal(pan.ok, false); + assert.match(pan.error, /Not run: the fly-to in this reply found nothing/); + assert.equal(getActiveCameraMotion(), null); + // The same holds for OpenAI's reply identity. + const group = 'response-1'; + await runner('fly_to_location', { query: 'Xyzzyville' }, { authorityGroup: group }); + const tilt = await runner( + 'move_camera', + { motion: 'tilt', pitchDeg: -45 }, + { authorityGroup: group }, + ); + assert.equal(tilt.ok, false); + assert.match(tilt.error, /found nothing/); +}); + +test('a motion in a later reply runs after a fly-to that found nothing', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + await runner('fly_to_location', { query: 'Xyzzyville' }, { reply: { turn: 1 } }); + const pan = await runner( + 'move_camera', + { motion: 'pan', direction: 'left' }, + { reply: { turn: 2 } }, + ); + assert.equal(pan.ok, true, pan.error); +}); + +test('a motion runs at a flight of its reply that landed after another lookup found nothing', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + const reply = { turn: 1 }; + await runner('fly_to_location', { query: 'Xyzzyville' }, { reply }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' }, { reply })).ok, true); + flights[0].complete(); + const pan = await runner( + 'move_camera', + { motion: 'pan', direction: 'left' }, + { reply }, + ); + assert.equal(pan.ok, true, pan.error); +}); + +test('a motion queued behind a fly-to yields to a search started during the flight', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights, panelSearch, currentGeneration } = + tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const search = panelSearch(); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /Not run: another navigation took the camera/); + assert.equal(getActiveCameraMotion(), null); + assert.equal(currentGeneration(), search, 'the search still owns navigation'); +}); + +test('a motion behind a geocoded fly-to yields to a search started while it flies', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, panelSearch } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + const fly = runner('fly_to_location', { query: 'Place A' }); + const moved = runner('move_camera', { motion: 'orbit' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + searches[0].resolve({ label: 'Place A', navigationMode: 'city-overview' }); + assert.equal((await fly).ok, true); + panelSearch(); + searches[0].options.onComplete(); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /another navigation took the camera/); + assert.equal(getActiveCameraMotion(), null); +}); + +test('a search before the fly-to does not drop the motion queued behind it', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights, panelSearch } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + panelSearch(); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + await new Promise((resolve) => setTimeout(resolve, 0)); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, 'tilt'); +}); + +test('a fly-to that stops a running motion says so', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + // The model sent the orbit first, so it ran at once. + const orbit = await runner('move_camera', { motion: 'orbit', mode: 'continuous' }); + assert.equal(orbit.ok, true); + const fly = await runner('fly_to_location', { locationId: 'austin' }); + assert.equal(fly.ok, true); + assert.equal(fly.stoppedMotion, 'orbit'); + const again = await runner('fly_to_location', { locationId: 'austin' }); + assert.equal('stoppedMotion' in again, false); +}); + +test('bad move_camera arguments are refused without waiting for a flight', async () => { + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'pan' }); + const movedDone = settledYet(moved); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), true, 'the refusal must not wait on the flight'); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /needs a direction/); +}); + +test('stop does not wait for a voice flight', async () => { + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + await runner('fly_to_location', { locationId: 'austin' }); + const result = await runner('move_camera', { motion: 'stop' }); + assert.equal(result.ok, true); + assert.equal(result.motion, 'stop'); +}); + test('nearest-aircraft voice action serializes layer enable, arrival, refresh, airborne query, and selection', async () => { globalThis.window = globalThis.window || { clearTimeout, diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 1234fb61e..9b5684243 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -453,10 +453,12 @@ const layers = [ export const VOICE_OFF_LAYERS = Object.freeze({ 'bhote-koshi-2026': 'scene-driven event layer (control_scene)', 'bhote-koshi-locator': 'scene-driven event layer (control_scene)', - // The Realtime prompt is at its 52,000-character budget; voice toggle and + // The Realtime prompt is at its 55,500-character budget; voice toggle and // query for this layer wait until there is room. 'drone-airspace': 'informational airspace layer; no room in the prompt budget', + 'wildlife-sightings': + 'community observation layer; no room in the prompt budget', }); const TYPE_NAMES = Object.freeze({ diff --git a/src/voice/panel/host.test.mjs b/src/voice/panel/host.test.mjs index 3c3b3332a..57b512ac0 100644 --- a/src/voice/panel/host.test.mjs +++ b/src/voice/panel/host.test.mjs @@ -1298,6 +1298,77 @@ test('undocking while a tray is open brings the panel back as its bar', async (t assert.equal($('#gev-chat-panel').dataset.placement, 'compact'); }); +test('opening chat or closing the voice panel clears a stale voice error', async (t) => { + for (const leave of ['chat', 'close']) { + const f = mounted(t, { view: true }); + await f.commands.session.start(); + f.commands.voicePanel.select('preview'); + f.instances[0].hooks.emit({ + type: 'state', + state: 'error', + detail: 'Lost', + }); + await f.settle(); + assert.equal($('#gev-voice-control').dataset.status, 'error'); + assert.equal(f.commands.session.isActive(), false); + if (leave === 'chat') $('#gev-chat-button').click(); + else $('#gev-voice-settings-close').click(); + await f.settle(); + assert.equal(f.commands.session.state, 'idle', leave); + assert.notEqual($('#gev-voice-control').dataset.status, 'error', leave); + } +}); + +test('switching AI Provider from chat Settings keeps chat Settings on the new mount', async (t) => { + const view = mountVoiceView(t); + const host = createVoicePanelHost({ storage: null }); + const instances = []; + const commands = createProviderCommands( + { + runner: async () => ({}), + dataManager: { layers: new Map() }, + supportedProviders: ['openai', 'gemini'], + }, + { + bind: (options) => + bind({ + ...options, + panelHost: host, + createControl: (control) => + createTestVoiceUi({ ...control, pinia: view.pinia }), + }), + factories: { openai: factory(instances), gemini: factory(instances) }, + storage: null, + }, + ); + t.after(() => { + commands.destroy(); + host.destroy(); + }); + await view.settle(); + $('#gev-chat-button').click(); + await view.settle(); + $('#gev-chat-panel').getBoundingClientRect = () => ({ + width: 320, + height: 480, + }); + $('#gev-voice-settings-button').click(); + await view.settle(); + const old = commands.ui; + assert.equal(old.state.voicePanel.context, 'chat'); + old.emit('provider-change', 'gemini'); + await view.settle(); + const { ui } = commands; + assert.notEqual(ui, old); + assert.equal(ui.state.voicePanel.context, 'chat'); + assert.equal(ui.state.voicePanel.frameWidth, 320); + assert.equal(ui.state.voicePanel.frameHeight, 480); + assert.equal(ui.state.chat.backVisible, true); + assert.equal(ui.state.root.assistantMode, 'chat'); + assert.equal($('#gev-chat-back').hidden, false); + assert.equal(active(), 'gev-voice-provider'); +}); + test('the voice panel close turns voice off', async (t) => { const f = mounted(t, { view: true }); await f.commands.session.start(); diff --git a/src/voice/pointAndAsk.test.mjs b/src/voice/pointAndAsk.test.mjs index 00c062d9f..5d6d0fdd1 100644 --- a/src/voice/pointAndAsk.test.mjs +++ b/src/voice/pointAndAsk.test.mjs @@ -309,7 +309,10 @@ test('markup: the chip sits under the user caption with a screen-reader separato assert.equal(pointer.hidden, true); const separator = pointer.querySelector('.gev-voice-card-sr'); assert.equal(separator.textContent, ': '); - assert.equal(separator.previousElementSibling.id, 'gev-voice-card-pointer-kind'); + assert.equal( + separator.previousElementSibling.id, + 'gev-voice-card-pointer-kind', + ); }); test('deixis prompt lint: one short rule, and every sentinel it names exists in the schema', () => { @@ -347,15 +350,18 @@ test('deixis prompt lint: one short rule, and every sentinel it names exists in // the analyst area scopes) raised the ceiling to 55,500; tightening the // AREAS rule and the area tool descriptions left it at 55,103 as measured // below (every tool, osm_query included); main's Sea Marks and Ski Pistes - // layers bring it to 55,210, ~290 under. A new tool or rule has to fit - // under this ceiling. + // layers bring it to 55,210, and the tilt pitchDeg argument (with its -45 + // default) and main's cockpit entry rule to 55,339. integrationContract's + // Overpass prompt is the tightest, at 55,499 (1 under). A new tool or rule + // has to fit under this ceiling. const size = instructions.length + JSON.stringify(GEV_REALTIME_TOOLS).length; assert.ok(size <= 55_500, `prompt size ${size}`); }); - test('after-tool speech allows returned items to answer requested lists and rankings', () => { - const rule = realtimeInstructions().split('\n').find(line => line.startsWith('AFTER TOOLS:')); + const rule = realtimeInstructions() + .split('\n') + .find((line) => line.startsWith('AFTER TOOLS:')); assert.match(rule, /requested analyst list or ranking/); assert.match(rule, /returned items.*returned values/); assert.match(rule, /keep their order/); diff --git a/src/voice/providerCommands.js b/src/voice/providerCommands.js index 66201141e..5d77ebef6 100644 --- a/src/voice/providerCommands.js +++ b/src/voice/providerCommands.js @@ -64,6 +64,8 @@ export function createProviderCommands(options, { bind, factories, storage }) { const textOnly = Boolean( current.session.adapter.getStartOptions?.().textOnly, ); + // Settings opened from chat stay chat's Settings. + const presentation = current.chatPresentation?.() ?? null; const generation = ++mountGeneration; voiceEnabled = false; current.session.destroy(); @@ -80,7 +82,7 @@ export function createProviderCommands(options, { bind, factories, storage }) { } catch { /* Storage is optional. */ } - mount(); + mount(presentation); if (resume && !disposed && provider === next) void current.session.start({ pushToTalk, @@ -142,7 +144,7 @@ export function createProviderCommands(options, { bind, factories, storage }) { return typeof value === 'function' ? value.bind(current) : value; }, }); - function mount() { + function mount(presentation = null) { voiceEnabled = false; const generation = ++mountGeneration; current = bind({ @@ -158,6 +160,7 @@ export function createProviderCommands(options, { bind, factories, storage }) { handleSessionEvent(event); }, createSession: factories[provider], + presentation, }); window.__gevVoiceCommands = facade; } diff --git a/src/voice/providerCommands.test.mjs b/src/voice/providerCommands.test.mjs index 41d092e73..a9acd2804 100644 --- a/src/voice/providerCommands.test.mjs +++ b/src/voice/providerCommands.test.mjs @@ -856,3 +856,18 @@ for (const action of ['stop', 'destroy']) { }); } } + +test('a provider switch hands chat Settings to the new mount', async (t) => { + const f = compositionFixture(t); + const first = f.facade.provider; + const next = first === 'openai' ? 'gemini' : 'openai'; + // Without chat open there is nothing to carry. + assert.equal(f.mounts[0].controls.chatPresentation(), null); + const shown = { view: 'settings', frame: { width: 320, height: 480 } }; + f.mounts[0].controls.chatPresentation = () => shown; + f.select(next); + await settle(); + assert.equal(f.mounts.length, 2); + assert.deepEqual(f.mounts[1].options.presentation, shown); + assert.equal(f.facade.provider, next); +}); diff --git a/src/voice/session.js b/src/voice/session.js index 21400f04c..7c31f17d2 100644 --- a/src/voice/session.js +++ b/src/voice/session.js @@ -185,6 +185,10 @@ export function createVoiceSession({ createAdapter, runner, signal }) { }; } +// Live, or left in error: stopping either returns the session to idle. +export const isStoppable = (session) => + session.isActive() || session.state === 'error'; + function abortError() { return new DOMException('Voice action cancelled', 'AbortError'); } diff --git a/src/voice/sessionCommands.js b/src/voice/sessionCommands.js index 78a1816b3..242a2126b 100644 --- a/src/voice/sessionCommands.js +++ b/src/voice/sessionCommands.js @@ -3,7 +3,7 @@ import { bindAssistantPanelLayout, bindLayoutDock } from './panelLayout.js'; import { bindChatControl } from './chatControl.js'; import { bindVoiceMiniPreview } from './voiceMiniPreview.js'; import { createVoiceControl, voiceStatusLabel } from './control.js'; -import { createVoiceSession } from './session.js'; +import { createVoiceSession, isStoppable } from './session.js'; import { VoiceCardControls } from './voiceCard.js'; import { PointerReticle } from './pointerReticle.js'; import { VOICE_PROVIDERS } from './providerCapabilities.js'; @@ -40,6 +40,8 @@ export function createVoiceCommands({ createReticle = createDefaultReticle, panelHost = null, nativePanelProvider = null, + // Chat's Settings, carried across a provider switch. + presentation = null, }) { if (resetExisting) window.__gevVoiceCommands?.stop?.({ removeUi: true }); const ui = createControl(panelHost ? { panel: true } : {}); @@ -225,6 +227,8 @@ export function createVoiceCommands({ onOpen: () => panelMount?.panel.close(), }) : null; + chat?.restorePresentation(presentation); + controls.chatPresentation = () => chat?.presentation() ?? null; function minimizeVoicePanel() { const panel = ui.element('voicePanel'); const holdsFocus = panel?.contains?.(globalThis.document?.activeElement); @@ -284,7 +288,8 @@ export function createVoiceCommands({ // The panel's close turns voice off, as chat's close ends the chat. A typed // session belongs to chat, which handles its own close. const offClose = ui.on('settings-close', () => { - if (!session.isActive() || adapter.getStartOptions?.().textOnly) return; + // A voice error the user closes is over too; stopping returns to idle. + if (!isStoppable(session) || adapter.getStartOptions?.().textOnly) return; const ticket = panelMount?.stopTicket(); session.stop(); panelMount?.closeAfterStop(ticket); diff --git a/src/voice/voiceCardPlanSteps.i18n.test.mjs b/src/voice/voiceCardPlanSteps.i18n.test.mjs index 4ac15394c..1cd8d9d65 100644 --- a/src/voice/voiceCardPlanSteps.i18n.test.mjs +++ b/src/voice/voiceCardPlanSteps.i18n.test.mjs @@ -295,10 +295,10 @@ test('area, imagery and OSM card lines are shown in the UI language', () => { notDrawn: 'the map was cleared while looking it up', }; const areaLines = inLocale('de', () => presentResult('resolve_area', area).display.lines); - assert.ok(areaLines.includes('250 m um das Wahrzeichen'), areaLines.join(' | ')); - assert.ok(areaLines.includes('Teile: 3')); - assert.ok(areaLines.includes('Gezeichnete Teile: 2 von 3')); - assert.ok(areaLines.some((line) => line.includes('die Karte wurde während der Suche geleert'))); + assert.ok(areaLines.includes('250 m um den Ort'), areaLines.join(' | ')); + assert.ok(areaLines.includes('3 Teile')); + assert.ok(areaLines.includes('2 von 3 Teilen gezeichnet')); + assert.ok(areaLines.some((line) => line.includes('die Zeichenfläche wurde während der Suche geleert'))); const osm = { ok: true, kind: 'hospitals', @@ -316,7 +316,7 @@ test('area, imagery and OSM card lines are shown in the UI language', () => { assert.match(osmCard.title, /\bhospital\b/); assert.doesNotMatch(osmCard.title, /hospitals/, 'one place takes the singular'); assert.equal(presentResult('osm_query', { ...osm, complete: true }).display.title, '1 hospital'); - assert.ok(osmCard.lines.includes('Zusammengeführte Doppelkartierungen: 2')); + assert.ok(osmCard.lines.includes('2 Duplikate zusammengeführt')); assert.deepEqual(osmCard.notes, ['die ersten 500 aufgelistet', 'es gibt weitere']); const imagery = { ok: true, diff --git a/src/worldFocus.js b/src/worldFocus.js index 5b5ca7a8e..6ed3a3b32 100644 --- a/src/worldFocus.js +++ b/src/worldFocus.js @@ -9,6 +9,12 @@ export const WORLD_CLICK_FOCUS_DURATION_SEC = 1.9; export const WORLD_FOCUS_FRAMING = Object.freeze({ vessel: Object.freeze({ radiusM: 150, rangeM: 1200, pitchDeg: -30 }), fire: Object.freeze({ radiusM: 400, rangeM: 3000, pitchDeg: -35 }), + sighting: Object.freeze({ radiusM: 250, rangeM: 2500, pitchDeg: -40 }), + 'sighting-area': Object.freeze({ + radiusM: 11000, + rangeM: 60000, + pitchDeg: -55, + }), }); /** Validate a layer-owned focus target before camera policy can release tracking. */ @@ -23,7 +29,7 @@ export function isValidWorldFocusTarget(detail) { !Number.isFinite(position.z) ) return false; - // Vessel and fire targets are surface-anchored Earth positions. Merely + // These targets are surface-anchored Earth positions. Merely // finite coordinates near the ECEF origin cannot be flown to, and must be // rejected before the camera policy releases a current follow owner. const magnitude = Cesium.Cartesian3.magnitude(position); diff --git a/src/worldFocus.test.mjs b/src/worldFocus.test.mjs index f5e8146db..3a10b75eb 100644 --- a/src/worldFocus.test.mjs +++ b/src/worldFocus.test.mjs @@ -21,16 +21,28 @@ function stubCamera() { return { calls, heading: Cesium.Math.toRadians(45), - cancelFlight() { calls.cancelFlight += 1; }, - flyToBoundingSphere(sphere, options) { calls.flights.push({ sphere, options }); }, + cancelFlight() { + calls.cancelFlight += 1; + }, + flyToBoundingSphere(sphere, options) { + calls.flights.push({ sphere, options }); + }, }; } test('a focus request carries the clicked target to any registered listener', () => { const target = new EventTarget(); const seen = []; - const dispose = registerWorldFocusRequestListener(target, (event) => seen.push(event.detail)); - assert.equal(requestWorldFocus({ kind: 'vessel', position: POSITION, id: '123' }, target), true); + const dispose = registerWorldFocusRequestListener(target, (event) => + seen.push(event.detail), + ); + assert.equal( + requestWorldFocus( + { kind: 'vessel', position: POSITION, id: '123' }, + target, + ), + true, + ); assert.equal(seen.length, 1); assert.equal(seen[0].kind, 'vessel'); assert.equal(seen[0].id, '123'); @@ -44,7 +56,9 @@ test('a focus request carries the clicked target to any registered listener', () test('requests without a kind or a position are dropped, never dispatched', () => { const target = new EventTarget(); let count = 0; - target.addEventListener(WORLD_FOCUS_REQUEST_EVENT, () => { count += 1; }); + target.addEventListener(WORLD_FOCUS_REQUEST_EVENT, () => { + count += 1; + }); assert.equal(requestWorldFocus({ position: POSITION }, target), false); assert.equal(requestWorldFocus({ kind: 'vessel' }, target), false); assert.equal(requestWorldFocus(null, target), false); @@ -74,51 +88,94 @@ test('a refusing policy (cockpit owns the camera) never reaches the flight', () const result = routeWorldFocusRequest( { detail: { kind: 'vessel', id: '123', position: POSITION } }, () => false, - () => { flights += 1; }, + () => { + flights += 1; + }, ); assert.equal(result, false); assert.equal(flights, 0); }); test('malformed requests are inert', () => { - assert.equal(routeWorldFocusRequest(null, () => 'x', () => 'y'), false); - assert.equal(routeWorldFocusRequest({ detail: { kind: 'vessel' } }, () => 'x', () => 'y'), false); + assert.equal( + routeWorldFocusRequest( + null, + () => 'x', + () => 'y', + ), + false, + ); + assert.equal( + routeWorldFocusRequest( + { detail: { kind: 'vessel' } }, + () => 'x', + () => 'y', + ), + false, + ); }); // A request that cannot fly must never cost the user their tracking: the UI // policy releases the follow camera, so validation happens BEFORE it runs. test('an unflyable request never reaches the release policy', () => { const unflyable = [ - { kind: 'plane', position: POSITION }, // no framing for this kind - { kind: 'vessel', position: { x: NaN, y: 0, z: 0 } }, // malformed position - { kind: 'vessel', position: { x: 0, y: 0 } }, // truthy but incomplete + { kind: 'plane', position: POSITION }, // no framing for this kind + { kind: 'vessel', position: { x: NaN, y: 0, z: 0 } }, // malformed position + { kind: 'vessel', position: { x: 0, y: 0 } }, // truthy but incomplete { kind: 'vessel', id: '123', position: { x: 0, y: 0, z: 0 } }, // ECEF origin { kind: 'fire', id: 'fire-1', position: { x: 1, y: 1, z: 1 } }, // finite but inside Earth { kind: 'vessel', position: 'somewhere' }, - { kind: 'vessel', position: POSITION }, // missing owner id + { kind: 'vessel', position: POSITION }, // missing owner id { position: POSITION }, ]; for (const detail of unflyable) { - assert.equal(isValidWorldFocusTarget(detail), false, JSON.stringify(detail)); + assert.equal( + isValidWorldFocusTarget(detail), + false, + JSON.stringify(detail), + ); let policyRuns = 0; const result = routeWorldFocusRequest( { detail }, - () => { policyRuns += 1; return 'released'; }, + () => { + policyRuns += 1; + return 'released'; + }, () => 'flew', ); assert.equal(result, false, JSON.stringify(detail)); - assert.equal(policyRuns, 0, `the policy must not run for ${JSON.stringify(detail)}`); + assert.equal( + policyRuns, + 0, + `the policy must not run for ${JSON.stringify(detail)}`, + ); // Nor may such a request be dispatched in the first place. assert.equal(requestWorldFocus(detail, new EventTarget()), false); } - assert.equal(isValidWorldFocusTarget({ kind: 'vessel', id: '123', position: POSITION }), true); - assert.equal(isValidWorldFocusTarget({ kind: 'fire', id: 'fire-1', position: POSITION }), true); + assert.equal( + isValidWorldFocusTarget({ kind: 'vessel', id: '123', position: POSITION }), + true, + ); + assert.equal( + isValidWorldFocusTarget({ kind: 'fire', id: 'fire-1', position: POSITION }), + true, + ); }); test('the transfer flight supersedes any flight in progress and keeps the operator heading', () => { const camera = stubCamera(); - assert.equal(flyToWorldTarget({ camera }, { kind: 'vessel', id: '123', position: POSITION }), true); - assert.equal(camera.calls.cancelFlight, 1, 'a prior flight must be cancelled, not queued'); + assert.equal( + flyToWorldTarget( + { camera }, + { kind: 'vessel', id: '123', position: POSITION }, + ), + true, + ); + assert.equal( + camera.calls.cancelFlight, + 1, + 'a prior flight must be cancelled, not queued', + ); assert.equal(camera.calls.flights.length, 1); const { sphere, options } = camera.calls.flights[0]; assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.vessel.radiusM); @@ -131,39 +188,82 @@ test('the transfer flight supersedes any flight in progress and keeps the operat test('fires frame wider than vessels — a fire is read by its surroundings', () => { const camera = stubCamera(); - flyToWorldTarget({ camera }, { kind: 'fire', id: 'fire-1', position: POSITION }); + flyToWorldTarget( + { camera }, + { kind: 'fire', id: 'fire-1', position: POSITION }, + ); const { sphere, options } = camera.calls.flights[0]; assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.fire.radiusM); assert.equal(options.offset.range, WORLD_FOCUS_FRAMING.fire.rangeM); - assert.ok(WORLD_FOCUS_FRAMING.fire.rangeM > WORLD_FOCUS_FRAMING.vessel.rangeM); - assert.ok(WORLD_FOCUS_FRAMING.fire.radiusM > WORLD_FOCUS_FRAMING.vessel.radiusM); + assert.ok( + WORLD_FOCUS_FRAMING.fire.rangeM > WORLD_FOCUS_FRAMING.vessel.rangeM, + ); + assert.ok( + WORLD_FOCUS_FRAMING.fire.radiusM > WORLD_FOCUS_FRAMING.vessel.radiusM, + ); }); test('every framing is a real oblique standoff, not a nadir or an inside-out sphere', () => { const kinds = Object.keys(WORLD_FOCUS_FRAMING); - assert.deepEqual(kinds.sort(), ['fire', 'vessel']); + assert.deepEqual(kinds.sort(), [ + 'fire', + 'sighting', + 'sighting-area', + 'vessel', + ]); for (const kind of kinds) { const framing = WORLD_FOCUS_FRAMING[kind]; // Looking DOWN at the target, but obliquely — a nadir drop reads as a map. - assert.ok(framing.pitchDeg < 0, `${kind}: pitch must look down, got ${framing.pitchDeg}`); - assert.ok(framing.pitchDeg > -80, `${kind}: pitch must stay oblique, got ${framing.pitchDeg}`); + assert.ok( + framing.pitchDeg < 0, + `${kind}: pitch must look down, got ${framing.pitchDeg}`, + ); + assert.ok( + framing.pitchDeg > -80, + `${kind}: pitch must stay oblique, got ${framing.pitchDeg}`, + ); // Standing off outside the framed sphere, at a human-readable distance. assert.ok(framing.radiusM > 0, `${kind}: radius must be positive`); assert.ok( framing.rangeM > framing.radiusM, `${kind}: range ${framing.rangeM} must stand off outside radius ${framing.radiusM}`, ); - assert.ok(framing.rangeM <= 10000, `${kind}: range must stay close-in, got ${framing.rangeM}`); + // An obscured sighting frames its entire 0.2° privacy cell. + const maximumRange = kind === 'sighting-area' ? 60000 : 10000; + assert.ok( + framing.rangeM <= maximumRange, + `${kind}: range must fit its surface extent, got ${framing.rangeM}`, + ); } // Exact shipped values — a silent retune must show up as a failing test. - assert.deepEqual({ ...WORLD_FOCUS_FRAMING.vessel }, { radiusM: 150, rangeM: 1200, pitchDeg: -30 }); - assert.deepEqual({ ...WORLD_FOCUS_FRAMING.fire }, { radiusM: 400, rangeM: 3000, pitchDeg: -35 }); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING.vessel }, + { radiusM: 150, rangeM: 1200, pitchDeg: -30 }, + ); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING.fire }, + { radiusM: 400, rangeM: 3000, pitchDeg: -35 }, + ); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING.sighting }, + { radiusM: 250, rangeM: 2500, pitchDeg: -40 }, + ); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING['sighting-area'] }, + { radiusM: 11000, rangeM: 60000, pitchDeg: -55 }, + ); }); test('unknown kinds and missing viewers issue no flight', () => { const camera = stubCamera(); - assert.equal(flyToWorldTarget({ camera }, { kind: 'plane', position: POSITION }), false); + assert.equal( + flyToWorldTarget({ camera }, { kind: 'plane', position: POSITION }), + false, + ); assert.equal(flyToWorldTarget({ camera }, { kind: 'vessel' }), false); - assert.equal(flyToWorldTarget(null, { kind: 'vessel', position: POSITION }), false); + assert.equal( + flyToWorldTarget(null, { kind: 'vessel', position: POSITION }), + false, + ); assert.equal(camera.calls.flights.length, 0); });