From 14f88b3e08f61f5b069660dd593471544d3c93db Mon Sep 17 00:00:00 2001 From: Yehe Liu Date: Thu, 5 Nov 2026 16:07:16 -0500 Subject: [PATCH 1/8] feat: Wildlife Sightings layer from iNaturalist and Acartia Recent community wildlife observations, as a new Wildlife group in the layer panel. - Server: keyless iNaturalist proxy (v2 observations with only the projected fields; undocumented v1 UTFGrid density tiles, decoded on the server) sharing one paced queue (burst 4, ~1/s, 60/min, 8,000/day budget, up to four in flight), in-memory caches and upstream-only cooldowns; Acartia Salish Sea marine-mammal feed rounded to 0.01 degrees with comments dropped. - Layer: grid counts drawn as a soft view-ranked density glow; real sampled sightings as taxon badges at their true positions with ambient photo thumbnails and labels; obscured records stay privacy cells; a clicked sighting stays pinned through camera moves and refreshes. - Anchored photo card per selection with the single iNaturalist link; attribution in the layer row and the Data attribution popover. - Filters (CONFIRMED, taxa presets) round-trip in share links with token 9. Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 5 + README.md | 3 +- SECURITY.md | 2 +- changelog.d/0000-wildlife-sightings.md | 12 + docs/CURRENT-STATE.md | 90 + docs/KNOWN-ISSUES.md | 16 + index.html | 2 +- package.json | 7 +- scripts/format-scope.json | 86 +- scripts/package-boundaries.json | 157 +- scripts/run-perf-tests.mjs | 59 + server/providers/acartia.js | 179 ++ server/providers/acartia.test.mjs | 381 +++ server/providers/common/userAgent.js | 4 + server/providers/inaturalist/client.js | 370 +++ server/providers/inaturalist/client.test.mjs | 163 ++ server/providers/inaturalist/grid.fixture.mjs | 51 + server/providers/inaturalist/grid.js | 127 + server/providers/inaturalist/grid.test.mjs | 374 +++ server/providers/inaturalist/observations.js | 343 +++ .../inaturalist/observations.test.mjs | 893 +++++++ .../providers/inaturalist/proxy.fixture.mjs | 70 + server/providers/inaturalist/queue.js | 109 + server/providers/inaturalist/queue.test.mjs | 111 + server/providers/inaturalist/speed.test.mjs | 218 ++ server/providers/local.js | 4 + src/app/catalog.js | 1 + src/app/catalog.test.mjs | 2 + src/app/constructCatalog.js | 5 + src/app/constructCatalog.test.mjs | 4 +- src/app/layers/wildlifeSightings.js | 30 + src/app/sourceComposition.js | 1 + src/app/sourceComposition.test.mjs | 2 +- src/data/dataCredits.js | 12 + src/data/layerState.js | 23 + src/data/layerState.test.mjs | 41 +- src/data/layerStateTokenLedger.test.mjs | 40 +- src/data/layerStateTokenReservations.json | 1 + src/data/spriteOrder.js | 1 + src/i18n/locales/ar.json | 59 +- src/i18n/locales/de.json | 59 +- src/i18n/locales/es.json | 59 +- src/i18n/locales/fr.json | 59 +- src/i18n/locales/it.json | 59 +- src/i18n/locales/ja.json | 59 +- src/i18n/locales/pt.json | 59 +- src/i18n/locales/ru.json | 59 +- src/i18n/locales/uk.json | 59 +- src/i18n/locales/zh-CN.json | 59 +- src/i18n/locales/zh-Hant.json | 59 +- src/i18n/same-as-english.json | 76 +- src/layers/wildlifeSightings/acartiaTaxa.js | 155 ++ .../wildlifeSightings/acartiaTaxa.test.mjs | 37 + .../wildlifeSightings/acquisition.test.mjs | 944 +++++++ .../wildlifeSightings/billboardModel.js | 114 + src/layers/wildlifeSightings/bounds.js | 51 + .../cache.integration.test.mjs | 111 + src/layers/wildlifeSightings/cards.js | 281 +++ src/layers/wildlifeSightings/cards.test.mjs | 627 +++++ src/layers/wildlifeSightings/controls.js | 250 ++ src/layers/wildlifeSightings/density.js | 462 ++++ src/layers/wildlifeSightings/density.test.mjs | 508 ++++ src/layers/wildlifeSightings/docs.test.mjs | 111 + src/layers/wildlifeSightings/gridViewport.js | 178 ++ .../wildlifeSightings/gridViewport.test.mjs | 142 ++ src/layers/wildlifeSightings/index.js | 130 + src/layers/wildlifeSightings/lifecycle.js | 804 ++++++ src/layers/wildlifeSightings/model.js | 208 ++ .../wildlifeSightings/model.perf.test.mjs | 76 + src/layers/wildlifeSightings/model.test.mjs | 162 ++ .../wildlifeSightings/ownership.test.mjs | 2224 +++++++++++++++++ src/layers/wildlifeSightings/performance.js | 115 + .../wildlifeSightings/performance.test.mjs | 299 +++ src/layers/wildlifeSightings/photoText.js | 36 + src/layers/wildlifeSightings/photos.js | 183 ++ src/layers/wildlifeSightings/photos.test.mjs | 147 ++ src/layers/wildlifeSightings/policy.js | 142 ++ src/layers/wildlifeSightings/records.js | 350 +++ src/layers/wildlifeSightings/records.test.mjs | 542 ++++ .../wildlifeSightings/render.perf.test.mjs | 166 ++ .../renderPerformanceFixtures.mjs | 185 ++ src/layers/wildlifeSightings/rendering.js | 691 +++++ .../wildlifeSightings/rendering.test.mjs | 544 ++++ src/layers/wildlifeSightings/selection.js | 560 +++++ .../wildlifeSightings/selection.test.mjs | 301 +++ src/layers/wildlifeSightings/source.js | 557 +++++ src/layers/wildlifeSightings/source.test.mjs | 816 ++++++ src/layers/wildlifeSightings/sprites.js | 136 + src/layers/wildlifeSightings/sprites.test.mjs | 189 ++ src/layers/wildlifeSightings/viewport.js | 83 + .../wildlifeSightings/viewport.test.mjs | 136 + src/materialSymbolsSubset.test.mjs | 3 + src/navigationPolicy.js | 2 + src/overlays/wildlifeSightingOverlay.test.mjs | 186 ++ src/overlays/worldOverlay.js | 203 +- src/overlays/worldOverlay.test.mjs | 174 ++ src/overlays/worldOverlayChrome.js | 29 + src/overlays/worldOverlayDraw.js | 115 +- src/overlays/worldOverlayDraw.test.mjs | 474 +++- src/perfTestRunner.test.mjs | 81 + src/share/viewCodec.js | 1 + src/sources/rateLimit.js | 19 +- src/sources/rateLimit.test.mjs | 23 + src/sources/reference.js | 2 + src/sources/reference.test.mjs | 3 + src/testSupport/wildlifeBillboardModel.mjs | 82 + src/tooling/previewServing.test.mjs | 4 + src/tooling/transitQa.test.mjs | 2 +- src/ui/applicationShell.js | 16 + src/ui/layerNames.js | 1 + src/ui/layerPanel.js | 4 + src/ui/layerPanel.test.mjs | 21 + src/ui/styles/cyber.css | 9 +- src/ui/views/panels/WildlifeSightingCard.vue | 131 + src/ui/wildlifeSightingCard.js | 270 ++ src/ui/wildlifeSightingCard.test.mjs | 767 ++++++ src/ui/wildlifeSightingPlacement.js | 274 ++ src/voice/layerManifest.js | 4 +- src/worldFocus.js | 8 +- src/worldFocus.test.mjs | 160 +- 120 files changed, 21260 insertions(+), 285 deletions(-) create mode 100644 changelog.d/0000-wildlife-sightings.md create mode 100644 scripts/run-perf-tests.mjs create mode 100644 server/providers/acartia.js create mode 100644 server/providers/acartia.test.mjs create mode 100644 server/providers/common/userAgent.js create mode 100644 server/providers/inaturalist/client.js create mode 100644 server/providers/inaturalist/client.test.mjs create mode 100644 server/providers/inaturalist/grid.fixture.mjs create mode 100644 server/providers/inaturalist/grid.js create mode 100644 server/providers/inaturalist/grid.test.mjs create mode 100644 server/providers/inaturalist/observations.js create mode 100644 server/providers/inaturalist/observations.test.mjs create mode 100644 server/providers/inaturalist/proxy.fixture.mjs create mode 100644 server/providers/inaturalist/queue.js create mode 100644 server/providers/inaturalist/queue.test.mjs create mode 100644 server/providers/inaturalist/speed.test.mjs create mode 100644 src/app/layers/wildlifeSightings.js create mode 100644 src/layers/wildlifeSightings/acartiaTaxa.js create mode 100644 src/layers/wildlifeSightings/acartiaTaxa.test.mjs create mode 100644 src/layers/wildlifeSightings/acquisition.test.mjs create mode 100644 src/layers/wildlifeSightings/billboardModel.js create mode 100644 src/layers/wildlifeSightings/bounds.js create mode 100644 src/layers/wildlifeSightings/cache.integration.test.mjs create mode 100644 src/layers/wildlifeSightings/cards.js create mode 100644 src/layers/wildlifeSightings/cards.test.mjs create mode 100644 src/layers/wildlifeSightings/controls.js create mode 100644 src/layers/wildlifeSightings/density.js create mode 100644 src/layers/wildlifeSightings/density.test.mjs create mode 100644 src/layers/wildlifeSightings/docs.test.mjs create mode 100644 src/layers/wildlifeSightings/gridViewport.js create mode 100644 src/layers/wildlifeSightings/gridViewport.test.mjs create mode 100644 src/layers/wildlifeSightings/index.js create mode 100644 src/layers/wildlifeSightings/lifecycle.js create mode 100644 src/layers/wildlifeSightings/model.js create mode 100644 src/layers/wildlifeSightings/model.perf.test.mjs create mode 100644 src/layers/wildlifeSightings/model.test.mjs create mode 100644 src/layers/wildlifeSightings/ownership.test.mjs create mode 100644 src/layers/wildlifeSightings/performance.js create mode 100644 src/layers/wildlifeSightings/performance.test.mjs create mode 100644 src/layers/wildlifeSightings/photoText.js create mode 100644 src/layers/wildlifeSightings/photos.js create mode 100644 src/layers/wildlifeSightings/photos.test.mjs create mode 100644 src/layers/wildlifeSightings/policy.js create mode 100644 src/layers/wildlifeSightings/records.js create mode 100644 src/layers/wildlifeSightings/records.test.mjs create mode 100644 src/layers/wildlifeSightings/render.perf.test.mjs create mode 100644 src/layers/wildlifeSightings/renderPerformanceFixtures.mjs create mode 100644 src/layers/wildlifeSightings/rendering.js create mode 100644 src/layers/wildlifeSightings/rendering.test.mjs create mode 100644 src/layers/wildlifeSightings/selection.js create mode 100644 src/layers/wildlifeSightings/selection.test.mjs create mode 100644 src/layers/wildlifeSightings/source.js create mode 100644 src/layers/wildlifeSightings/source.test.mjs create mode 100644 src/layers/wildlifeSightings/sprites.js create mode 100644 src/layers/wildlifeSightings/sprites.test.mjs create mode 100644 src/layers/wildlifeSightings/viewport.js create mode 100644 src/layers/wildlifeSightings/viewport.test.mjs create mode 100644 src/overlays/wildlifeSightingOverlay.test.mjs create mode 100644 src/overlays/worldOverlayChrome.js create mode 100644 src/perfTestRunner.test.mjs create mode 100644 src/sources/rateLimit.test.mjs create mode 100644 src/testSupport/wildlifeBillboardModel.mjs create mode 100644 src/ui/views/panels/WildlifeSightingCard.vue create mode 100644 src/ui/wildlifeSightingCard.js create mode 100644 src/ui/wildlifeSightingCard.test.mjs create mode 100644 src/ui/wildlifeSightingPlacement.js diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index ed0895b0a..1153015b8 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 | @@ -195,6 +197,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 (undocumented endpoint 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 outside the documented v1 contract; 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. 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. diff --git a/README.md b/README.md index 6212f3991..bce41b190 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 | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | @@ -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 | 🟢 | 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..d7837af7f --- /dev/null +++ b/src/ui/wildlifeSightingCard.test.mjs @@ -0,0 +1,767 @@ +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('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/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/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); }); From ecbe1d99e67f09f622f1afe6a8bb450edddc0909 Mon Sep 17 00:00:00 2001 From: Yehe Liu Date: Thu, 5 Nov 2026 16:18:27 -0500 Subject: [PATCH 2/8] docs: number the wildlife sightings changelog fragment (#1591) Co-Authored-By: Claude Opus 5.5 --- .../{0000-wildlife-sightings.md => 1591-wildlife-sightings.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/{0000-wildlife-sightings.md => 1591-wildlife-sightings.md} (95%) diff --git a/changelog.d/0000-wildlife-sightings.md b/changelog.d/1591-wildlife-sightings.md similarity index 95% rename from changelog.d/0000-wildlife-sightings.md rename to changelog.d/1591-wildlife-sightings.md index 75abdd8dd..64f4e7c54 100644 --- a/changelog.d/0000-wildlife-sightings.md +++ b/changelog.d/1591-wildlife-sightings.md @@ -9,4 +9,4 @@ Acartia comments are excluded. Attribution appears in the layer row and Data attribution popover. Cached progressive tiles, bounded image/billboard work and a paced queue preserve iNaturalist's 60/minute and 8,000/day limits. - All 11 locales supported. (lyehe, #0000) + All 11 locales supported. (lyehe, #1591) From 82138478d0ff25c57f5f906e494ed0beca723437 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:03:52 -0800 Subject: [PATCH 3/8] fix(wildlife): treat a valid empty Acartia snapshot as a fresh success A quiet or fully filtered feed raised invalid_snapshot, so the proxy answered 502 with a 15 s cooldown and re-polled about four times a minute. Only a non-array payload is malformed now; an empty projection caches for the normal ten minutes and clears earlier rows. --- docs/CURRENT-STATE.md | 5 +- server/providers/acartia.js | 4 +- server/providers/acartia.test.mjs | 84 ++++++++++++++++++++++--------- 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1b1b73d2c..b615bb70d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -123,7 +123,10 @@ known `Retry-After`. Failures keep filters and drawn rows, reporting DEGRADED with retry timing; logs exclude request URLs and upstream exception text. `/api/acartia/sightings` adds Salish Sea marine mammals within 45.5–50.5° N, -126–121° W, cached ten minutes and stale for at most 24 h. WHALES accepts only +126–121° W, cached ten minutes and stale for at most 24 h. A valid empty +snapshot, including one whose rows all fall outside coverage or the species +checks, is a fresh success that clears earlier rows; only a malformed or failed +upstream reply serves stale and starts a retry cooldown. WHALES accepts only recognized cetaceans; other identified marine mammals appear under ALL or MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells; Acartia positions are rounded to 0.01° on the server. Acartia comments are never retained. diff --git a/server/providers/acartia.js b/server/providers/acartia.js index e3dafd220..c8f8b90d7 100644 --- a/server/providers/acartia.js +++ b/server/providers/acartia.js @@ -100,7 +100,9 @@ export function acartiaProxy({ signal, ); const rows = normalizeAcartiaSightings(payload, now()); - if (!rows?.length) throw new Error('invalid_snapshot'); + // Only a non-array is malformed. A quiet feed, or one whose rows all fall + // outside coverage or the species checks, is a valid empty snapshot. + if (!rows) throw new Error('invalid_snapshot'); return { fetchedAt: now(), rows }; } catch (error) { if (error.status !== 429) coolDown(502, FAILURE_COOLDOWN_MS); diff --git a/server/providers/acartia.test.mjs b/server/providers/acartia.test.mjs index bf61d987c..e7b3cd32e 100644 --- a/server/providers/acartia.test.mjs +++ b/server/providers/acartia.test.mjs @@ -221,43 +221,81 @@ test('one global snapshot single-flights and stays fresh for ten minutes', async assert.equal(calls, 2); }); -test('empty, unprojectable and invented wrapped snapshots never replace the cache', async () => { - for (const bad of [ +test('a valid empty or fully filtered snapshot is a fresh success that clears old rows', async () => { + for (const quiet of [ [], [null], [{ ...sighting(), data_source_name: '' }], - { results: [sighting()] }, + [{ ...sighting(), latitude: 37.8 }], + [{ ...sighting(), type: 'Unknown' }], ]) { let at = NOW; - let fail = false; + let calls = 0; + let empty = false; const cache = new Map(); const request = install({ cache, now: () => at, - fetchImpl: async () => Response.json(fail ? bad : [sighting()]), + fetchImpl: async () => { + calls += 1; + return Response.json(empty ? quiet : [sighting()]); + }, }); - const fresh = await request(); - const retained = cache.get('sightings'); - fail = true; + assert.equal((await request()).body.rows.length, 1); + empty = true; at += 600_001; - const stale = await request(); - assert.equal(stale.status, 200); - assert.equal(stale.headers['X-Data-Stale'], 'true'); - assert.equal(stale.body.stale, true); - assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt); - assert.equal(cache.get('sightings'), retained); - const coldCache = new Map(); - const cold = install({ - cache: coldCache, - fetchImpl: async () => Response.json(bad), - }); - const result = await cold(); - assert.equal(result.status, 502); - assert.equal(result.body.error, 'acartia_unavailable'); - assert.equal(coldCache.size, 0); + const result = await request(); + assert.equal(result.status, 200); + assert.deepEqual(result.body, { fetchedAt: at, rows: [] }); + assert.equal(result.headers['X-Data-Stale'], undefined); + assert.equal(result.headers['Retry-After'], undefined); + assert.deepEqual(cache.get('sightings').value.rows, []); + // The quiet feed keeps the normal ten-minute cadence, not a failure retry. + at += 599_999; + assert.equal((await request()).status, 200); + assert.equal(calls, 2); + at += 1; + await request(); + assert.equal(calls, 3); + const cold = install({ fetchImpl: async () => Response.json(quiet) }); + const coldResult = await cold(); + assert.equal(coldResult.status, 200); + assert.deepEqual(coldResult.body, { fetchedAt: NOW, rows: [] }); } }); +test('an invented wrapped snapshot never replaces the cache', async () => { + const bad = { results: [sighting()] }; + let at = NOW; + let fail = false; + const cache = new Map(); + const request = install({ + cache, + now: () => at, + fetchImpl: async () => Response.json(fail ? bad : [sighting()]), + }); + const fresh = await request(); + const retained = cache.get('sightings'); + fail = true; + at += 600_001; + const stale = await request(); + assert.equal(stale.status, 200); + assert.equal(stale.headers['X-Data-Stale'], 'true'); + assert.equal(stale.body.stale, true); + assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt); + assert.equal(cache.get('sightings'), retained); + const coldCache = new Map(); + const cold = install({ + cache: coldCache, + fetchImpl: async () => Response.json(bad), + }); + const result = await cold(); + assert.equal(result.status, 502); + assert.equal(result.body.error, 'acartia_unavailable'); + assert.equal(result.headers['Retry-After'], '15'); + assert.equal(coldCache.size, 0); +}); + test('failure serves stale for at most 24 hours and never reports a cold success', async () => { let at = NOW; let fail = false; From 3cb30c3f975e8dfb87a3e7105a650c509d93b263 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:05:02 -0800 Subject: [PATCH 4/8] fix(wildlife): point the Acartia card link only at acartia.io The card's "Acartia" link was built from the contributor-supplied photo_url, so any https site could appear labelled as Acartia. Acartia has no documented per-sighting page, so the source link is now always the Acartia site, on the server projection and again in the card. The separate "Open on Acartia" fallback link is no longer needed. --- docs/CURRENT-STATE.md | 3 +- src/i18n/locales/ar.json | 1 - src/i18n/locales/de.json | 1 - src/i18n/locales/es.json | 1 - src/i18n/locales/fr.json | 1 - src/i18n/locales/it.json | 1 - src/i18n/locales/ja.json | 1 - src/i18n/locales/pt.json | 1 - src/i18n/locales/ru.json | 1 - src/i18n/locales/uk.json | 1 - src/i18n/locales/zh-CN.json | 1 - src/i18n/locales/zh-Hant.json | 1 - src/layers/wildlifeSightings/records.js | 17 +++-------- src/layers/wildlifeSightings/records.test.mjs | 8 +++-- src/ui/views/panels/WildlifeSightingCard.vue | 5 ++-- src/ui/wildlifeSightingCard.test.mjs | 30 +++++++++++++++++++ 16 files changed, 45 insertions(+), 29 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b615bb70d..fbbaa19b0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -174,7 +174,8 @@ compact anchored photo card. Selection stays pinned across camera, filter and sample changes until explicit deselection or layer disable. Its medium image uses `object-fit: contain`, with names, compact observed age and creator/licence. The card is the single source link to iNaturalist. Obscured sightings also stay -text-only; Acartia stays text-only with provider credit and whale guidance. +text-only; Acartia stays text-only with provider credit and whale guidance, +and its source link is always `https://acartia.io/`, never a contributed URL. Placement avoids actual HUD, toolbar and rail rectangles, panning only when necessary while preserving zoom. Escape, close or an outside click closes the card and clears selection. Camera/layout changes reposition the card without diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index da291e035..b491c3c93 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3453,7 +3453,6 @@ "Loading photo...": "جارٍ تحميل الصورة…", "Photo unavailable": "الصورة غير متاحة", "Location obscured to a 0.2° cell": "الموقع مخفي ضمن خلية بمقدار 0.2°", - "Open on Acartia ↗": "فتح على Acartia ↗", "Photo of {name}": "صورة {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 69b039fc5..d01ff292f 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3153,7 +3153,6 @@ "Loading photo...": "Foto wird geladen…", "Photo unavailable": "Foto nicht verfügbar", "Location obscured to a 0.2° cell": "Standort auf eine 0,2°-Rasterzelle verschleiert", - "Open on Acartia ↗": "Auf Acartia öffnen ↗", "Photo of {name}": "Foto von {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a34a9bf39..aa403f743 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Cargando foto…", "Photo unavailable": "Foto no disponible", "Location obscured to a 0.2° cell": "Ubicación oculta en una celda de 0,2°", - "Open on Acartia ↗": "Abrir en Acartia ↗", "Photo of {name}": "Foto de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a09806cfa..b3ae72189 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Chargement de la photo…", "Photo unavailable": "Photo indisponible", "Location obscured to a 0.2° cell": "Localisation masquée dans une cellule de 0,2°", - "Open on Acartia ↗": "Ouvrir sur Acartia ↗", "Photo of {name}": "Photo de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index b800b010c..4c69e1d4c 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Caricamento della foto…", "Photo unavailable": "Foto non disponibile", "Location obscured to a 0.2° cell": "Posizione oscurata in una cella di 0,2°", - "Open on Acartia ↗": "Apri su Acartia ↗", "Photo of {name}": "Foto di {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index fbfc9dd96..9a053f869 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3078,7 +3078,6 @@ "Loading photo...": "写真を読み込み中…", "Photo unavailable": "写真を表示できません", "Location obscured to a 0.2° cell": "位置は0.2°のセル内に秘匿されています", - "Open on Acartia ↗": "Acartiaで開く ↗", "Photo of {name}": "{name}の写真", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index a57f4eeaf..79c507759 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Carregando foto…", "Photo unavailable": "Foto indisponível", "Location obscured to a 0.2° cell": "Localização ocultada em uma célula de 0,2°", - "Open on Acartia ↗": "Abrir no Acartia ↗", "Photo of {name}": "Foto de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 2946ba482..11fddb3e9 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3303,7 +3303,6 @@ "Loading photo...": "Загрузка фото…", "Photo unavailable": "Фото недоступно", "Location obscured to a 0.2° cell": "Местоположение скрыто в ячейке 0,2°", - "Open on Acartia ↗": "Открыть на Acartia ↗", "Photo of {name}": "Фото {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 7f6c8c3e2..c0fab8533 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3303,7 +3303,6 @@ "Loading photo...": "Завантаження фото…", "Photo unavailable": "Фото недоступне", "Location obscured to a 0.2° cell": "Місцеположення приховано в комірці 0,2°", - "Open on Acartia ↗": "Відкрити на Acartia ↗", "Photo of {name}": "Фото {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 28854ce5d..8687af790 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3078,7 +3078,6 @@ "Loading photo...": "正在加载照片…", "Photo unavailable": "照片不可用", "Location obscured to a 0.2° cell": "位置已模糊至0.2°网格", - "Open on Acartia ↗": "在Acartia上打开 ↗", "Photo of {name}": "{name}的照片", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 63088293a..a95e71dd5 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3078,7 +3078,6 @@ "Loading photo...": "正在載入照片…", "Photo unavailable": "照片無法使用", "Location obscured to a 0.2° cell": "位置已模糊至0.2°網格", - "Open on Acartia ↗": "在Acartia上開啟 ↗", "Photo of {name}": "{name}的照片", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/layers/wildlifeSightings/records.js b/src/layers/wildlifeSightings/records.js index a1e50f52c..1af1cc296 100644 --- a/src/layers/wildlifeSightings/records.js +++ b/src/layers/wildlifeSightings/records.js @@ -247,18 +247,9 @@ export function normalizeInatObservations( return rows; } -function acartiaLink(value) { - if (typeof value !== 'string' || value.length > 500) - return 'https://acartia.io'; - try { - const url = new URL(value); - return url.protocol === 'https:' && !url.username && !url.password - ? url.href - : 'https://acartia.io'; - } catch { - return 'https://acartia.io'; - } -} +// Acartia has no documented per-sighting page, and photo_url is supplied by the +// contributor. The source link is therefore always the Acartia site itself. +export const ACARTIA_SITE = 'https://acartia.io/'; /** Acartia documents flat rows only. Name and entity are text fields; unknown * shapes are skipped rather than guessing nested provider data. */ @@ -315,7 +306,7 @@ export function normalizeAcartiaSightings(payload, nowMs) { cetacean: speciesGroup === 'cetacean', threatened: false, provider: { name, entity: cleanText(raw.data_source_entity, 60) }, - link: acartiaLink(raw.photo_url), + link: ACARTIA_SITE, photo: null, favesCount: 0, identificationAgreements: 0, diff --git a/src/layers/wildlifeSightings/records.test.mjs b/src/layers/wildlifeSightings/records.test.mjs index 18a1693d8..9909338a1 100644 --- a/src/layers/wildlifeSightings/records.test.mjs +++ b/src/layers/wildlifeSightings/records.test.mjs @@ -330,7 +330,7 @@ test('Acartia requires flat provider text, handles trust, and sanitizes type', ( ); }); -test('Acartia rejects invalid times/positions and unsafe photo links become the canonical page', () => { +test('Acartia rejects invalid times/positions and its link is always the Acartia site', () => { for (const overrides of [ { created: new Date(now - 8 * 24 * hour - 1).toISOString() }, { created: new Date(now + 10 * 60_000 + 1).toISOString() }, @@ -341,6 +341,10 @@ test('Acartia rejects invalid times/positions and unsafe photo links become the ]) assert.deepEqual(normalizeAcartiaSightings([sighting(overrides)], now), []); for (const photo_url of [ + // A contributor-supplied URL is never labelled as the Acartia source. + 'https://provider.example/photo.jpg', + 'https://phishing.example/acartia.io/login', + 'https://acartia.io.attacker.example/', 'http://provider.example/p.jpg', 'https://user:secret@provider.example/p.jpg', 'javascript:bad()', @@ -349,7 +353,7 @@ test('Acartia rejects invalid times/positions and unsafe photo links become the ]) { assert.equal( normalizeAcartiaSightings([sighting({ photo_url })], now)[0].link, - 'https://acartia.io', + 'https://acartia.io/', ); } }); diff --git a/src/ui/views/panels/WildlifeSightingCard.vue b/src/ui/views/panels/WildlifeSightingCard.vue index fc92c7ee4..9caac8fdd 100644 --- a/src/ui/views/panels/WildlifeSightingCard.vue +++ b/src/ui/views/panels/WildlifeSightingCard.vue @@ -2,7 +2,7 @@ import { computed, shallowRef, useTemplateRef, watch } from 'vue'; import { formatDate, t, translateKnown } from '../../../i18n/index.js'; import { photoCreator, formatAge } from '../../../layers/wildlifeSightings/photoText.js'; -import { inatLicenceLabel, inatPhotoUrl, isOpenPhoto } from '../../../layers/wildlifeSightings/records.js'; +import { ACARTIA_SITE, inatLicenceLabel, inatPhotoUrl, isOpenPhoto } from '../../../layers/wildlifeSightings/records.js'; const props = defineProps({ sighting: { type: Object, required: true }, @@ -36,6 +36,8 @@ const age = computed(() => { }); const observedDate = computed(() => observedDay.value ? formatDate(Date.parse(`${observedDay.value}T00:00:00Z`), { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }) : ''); const link = computed(() => { + // The "Acartia" label only ever points at the Acartia site, never a row URL. + if (acartia.value) return ACARTIA_SITE; try { const url = new URL(props.sighting.link); return url.protocol === 'https:' && !url.username && !url.password ? url.href : null; @@ -89,7 +91,6 @@ watch([() => props.sighting.stableId, () => photo.value?.src], () => { loaded.va

- {{ t('Open on Acartia ↗') }} {{ t('Be Whale Wise') }}

diff --git a/src/ui/wildlifeSightingCard.test.mjs b/src/ui/wildlifeSightingCard.test.mjs index d7837af7f..f685c75e0 100644 --- a/src/ui/wildlifeSightingCard.test.mjs +++ b/src/ui/wildlifeSightingCard.test.mjs @@ -373,6 +373,36 @@ test('missing, restricted, obscured and Acartia photos all use the same compact }); }); +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), From 5391687d4649c3fd9e7f0d0c46624caaf6c7582e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:06:41 -0800 Subject: [PATCH 5/8] fix(wildlife): draw what arrived when one sighting source fails An iNaturalist rejection discarded a successful Acartia reply, and the antimeridian Promise.all lost both halves when one failed. Each box now settles independently; the failed part is reported on sources.inaturalist (error and failed boxes), the layer keeps that area's last iNaturalist rows and shows DEGRADED with retry timing, and the update fails outright only when every requested source failed. --- docs/CURRENT-STATE.md | 4 + src/layers/wildlifeSightings/lifecycle.js | 16 +++- .../wildlifeSightings/ownership.test.mjs | 41 +++++++++++ src/layers/wildlifeSightings/source.js | 26 ++++++- src/layers/wildlifeSightings/source.test.mjs | 73 +++++++++++++++++++ 5 files changed, 155 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fbbaa19b0..2449130f4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -121,6 +121,10 @@ Provider 429 cooldown is shared; 5xx cooldown is resource-specific. Failed tiles retain good cells for at most one hour. Stale replies carry `X-Data-Stale` and known `Retry-After`. Failures keep filters and drawn rows, reporting DEGRADED with retry timing; logs exclude request URLs and upstream exception text. +A partial failure still draws what arrived: a failed iNaturalist request, or +one failed half of an antimeridian view, keeps a good Acartia reply and the +other half, retains that area's last iNaturalist rows and reports DEGRADED. +The update fails outright only when every requested source failed. `/api/acartia/sightings` adds Salish Sea marine mammals within 45.5–50.5° N, 126–121° W, cached ten minutes and stale for at most 24 h. A valid empty diff --git a/src/layers/wildlifeSightings/lifecycle.js b/src/layers/wildlifeSightings/lifecycle.js index e092a6ef1..6da425c7a 100644 --- a/src/layers/wildlifeSightings/lifecycle.js +++ b/src/layers/wildlifeSightings/lifecycle.js @@ -345,8 +345,18 @@ export function createLifecycle({ state, services, parts }) { state.loadedScope = query.scope; state.pendingClear = false; // A successful page replaces this queried area, including a healthy - // empty page. Keep only other visited areas in the bounded LRU. + // empty page. Keep only other visited areas in the bounded LRU. Areas + // whose iNaturalist request failed keep their last iNaturalist rows. + const inat = snapshot.sources?.inaturalist; + const failedInatBoxes = + inat?.ok === false ? inat.failedBoxes || query.boxes : null; for (const [id, row] of state.rows) { + if ( + failedInatBoxes && + row.origin === 'inaturalist' && + rowInWildlifeBoxes(row, failedInatBoxes, 0) + ) + continue; if (rowInWildlifeBoxes(row, query.boxes, 0)) state.rows.delete(id); } for (const row of snapshot.rows) { @@ -397,6 +407,10 @@ export function createLifecycle({ state, services, parts }) { const sourceError = gridError || staleError || + (snapshot.sources?.inaturalist?.ok === false + ? snapshot.sources.inaturalist.error || + new Error(msg('Wildlife sightings source unavailable')) + : null) || (snapshot.sources?.acartia?.ok === false ? new Error(msg('Acartia feed unavailable')) : null); diff --git a/src/layers/wildlifeSightings/ownership.test.mjs b/src/layers/wildlifeSightings/ownership.test.mjs index c8bfaac62..e118a87bc 100644 --- a/src/layers/wildlifeSightings/ownership.test.mjs +++ b/src/layers/wildlifeSightings/ownership.test.mjs @@ -1568,6 +1568,47 @@ test('provider credits sit in the layer row, never on the globe; Acartia failure h.layer.destroy(); }); +test('an iNaturalist failure beside a good Acartia reply keeps both and reports degraded', async () => { + let inatHealthy = true; + const acartia = { + ...base, + stableId: 'acartia:1', + origin: 'acartia', + iconic: 'Mammalia', + lon: -122.3, + provider: { name: 'Synthetic Provider', entity: null }, + }; + const failure = Object.assign(new Error('iNaturalist HTTP 502'), { + status: 502, + retryAfterSec: 30, + }); + const h = harness({ + getSnapshot: async () => [], + getSnapshotDetail: async ({ boxes }) => ({ + rows: inatHealthy ? [base, acartia] : [acartia], + sources: { + inaturalist: inatHealthy + ? { ok: true } + : { ok: false, error: failure, failedBoxes: boxes }, + acartia: { ok: true }, + }, + }), + }); + await h.update(); + assert.equal(h.layer.getStats().count, 2); + inatHealthy = false; + await h.update(); + // The failed iNaturalist area keeps its last rows; Acartia still refreshes. + assert.equal(h.layer.getStats().count, 2); + assert.equal(h.layer.getStats().error, 'iNaturalist HTTP 502'); + assert.equal(layerFeedState(h.layer.getStats()), 'degraded'); + assert.equal( + h.layer.getRowControls().info, + 'iNaturalist contributors · Acartia 1 (Salish Sea) · licences per record', + ); + h.layer.destroy(); +}); + test('ambient cards prefilter the horizon, spread over screen regions and retain visible slots', async () => { const rows = Array.from({ length: 32 }, (_, i) => ({ ...photographed, diff --git a/src/layers/wildlifeSightings/source.js b/src/layers/wildlifeSightings/source.js index 7af6d1e72..8bd41bf1d 100644 --- a/src/layers/wildlifeSightings/source.js +++ b/src/layers/wildlifeSightings/source.js @@ -454,7 +454,9 @@ export function createWildlifeSightingsSource({ ['all', 'Mammalia', 'whales'].includes(preset) && requestBoxes.some((bounds) => boxIntersects(bounds, ACARTIA_GUARD_BOX)); - const inatRequest = Promise.all( + // Each box settles independently: one failed antimeridian half must not + // discard the other half, and neither may discard a good Acartia reply. + const inatRequest = Promise.allSettled( requestBoxes.map((bounds) => { const params = new URLSearchParams({ scope: scope === 'global' ? 'global' : 'view', @@ -480,8 +482,23 @@ export function createWildlifeSightingsSource({ acartiaRequest, ]); signal?.throwIfAborted(); - if (inatResult.status === 'rejected') throw inatResult.reason; - const inatSnapshots = inatResult.value; + const inatSnapshots = []; + const failedBoxes = []; + let inatError = null; + inatResult.value.forEach((result, index) => { + if (result.status === 'fulfilled') inatSnapshots.push(result.value); + else { + failedBoxes.push(requestBoxes[index]); + inatError ??= result.reason; + } + }); + // Fail outright only when nothing succeeded; otherwise draw what arrived + // and report the failed source in its per-source status. + if ( + !inatSnapshots.length && + (!useAcartia || acartiaResult.status !== 'fulfilled') + ) + throw inatError; const inatRows = inatSnapshots.flatMap((snapshot) => snapshot.rows); const acartiaSnapshot = acartiaResult.status === 'fulfilled' ? acartiaResult.value : null; @@ -529,10 +546,11 @@ export function createWildlifeSightingsSource({ ...(retryAfter.length ? { retryAfterSec: Math.max(...retryAfter) } : {}), sources: { inaturalist: { - ok: true, + ok: !inatError, stale: inatStale, fetchedAt: inatFetchedAt, count: rows.filter((row) => row.origin === 'inaturalist').length, + ...(inatError ? { error: inatError, failedBoxes } : {}), }, acartia: { ok: !useAcartia || acartiaResult.status === 'fulfilled', diff --git a/src/layers/wildlifeSightings/source.test.mjs b/src/layers/wildlifeSightings/source.test.mjs index 7c1191e4b..2a21ca372 100644 --- a/src/layers/wildlifeSightings/source.test.mjs +++ b/src/layers/wildlifeSightings/source.test.mjs @@ -148,6 +148,79 @@ test('Acartia failure preserves iNat rows with an explicit failed source', async assert.equal(result.sources.inaturalist.ok, true); }); +test('iNat failure keeps a successful Acartia result and reports iNat degraded', async () => { + const source = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async (url) => + url.startsWith('/api/acartia') + ? Response.json(acartiaEnvelope()) + : new Response('', { + status: 502, + headers: { 'X-Upstream-Status': '503', 'Retry-After': '30' }, + }), + }); + const result = await source.getSnapshotDetail({ box: seattle }); + assert.deepEqual(result.rows, [acartia]); + assert.equal(result.totalResults, 1); + assert.equal(result.fetchedAt, now); + assert.equal(result.sources.acartia.ok, true); + assert.equal(result.sources.inaturalist.ok, false); + assert.equal(result.sources.inaturalist.count, 0); + assert.equal( + result.sources.inaturalist.error.message, + 'iNaturalist HTTP 502', + ); + assert.equal(result.sources.inaturalist.error.retryAfterSec, 30); + assert.deepEqual(result.sources.inaturalist.failedBoxes, [seattle]); +}); + +test('observations reject only when every requested source failed', async () => { + const source = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async (url) => + new Response('', { status: url.startsWith('/api/acartia') ? 502 : 503 }), + }); + await assert.rejects(source.getSnapshotDetail({ box: seattle }), { + message: 'iNaturalist HTTP 503', + }); + const antimeridian = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async () => new Response('', { status: 503 }), + }); + await assert.rejects( + antimeridian.getSnapshotDetail({ + boxes: [ + { south: 10, west: 179, north: 12, east: 180 }, + { south: 10, west: -180, north: 12, east: -178 }, + ], + }), + { message: 'iNaturalist HTTP 503' }, + ); +}); + +test('one failed antimeridian half keeps the other half and reports the failed box', async () => { + const east = { south: 10, west: 179, north: 12, east: 180 }; + const west = { south: 10, west: -180, north: 12, east: -178 }; + const eastRow = { ...inat, stableId: 'inat:east', lat: 11, lon: 179.5 }; + const source = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async (url) => + new URL(url, 'https://example.test').searchParams.get('west') === '179' + ? Response.json(inatEnvelope({ rows: [eastRow], totalResults: 3 })) + : new Response('', { status: 503 }), + }); + const result = await source.getSnapshotDetail({ boxes: [east, west] }); + assert.deepEqual(result.rows, [eastRow]); + assert.equal(result.totalResults, 3); + assert.equal(result.sources.inaturalist.ok, false); + assert.equal(result.sources.inaturalist.count, 1); + assert.equal( + result.sources.inaturalist.error.message, + 'iNaturalist HTTP 503', + ); + assert.deepEqual(result.sources.inaturalist.failedBoxes, [west]); +}); + test('proxy staleness and original fetch dates pass through with per-source counts', async () => { const fetchedAt = now - 50 * 60_000; const source = createWildlifeSightingsSource({ From fb0a58ef3c55dd03eae038df8d510140c71f16c0 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:07:48 -0800 Subject: [PATCH 6/8] docs(wildlife): cite the documented iNaturalist UTFGrid endpoint and Acartia's key request /grid/{z}/{x}/{y}.grid.json is documented in the v1 and v2 API docs as Grid Tiles UTFGrid; only its per-cell records are unspecified, which is what the schema guard covers. DATA_SOURCES.md now also notes that Acartia's README asks API users to create an account and an API key, although /sightings/current answers without one today. --- DATA_SOURCES.md | 4 ++-- docs/CURRENT-STATE.md | 3 ++- docs/KNOWN-ISSUES.md | 8 +++++--- src/layers/wildlifeSightings/docs.test.mjs | 18 ++++++++++++++++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 1153015b8..f2b002428 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -198,8 +198,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **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 (undocumented endpoint 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 outside the documented v1 contract; 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. Attribution also appears in the layer row and Data attribution popover. Opportunistic sightings, not a census. +- **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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2449130f4..96d85431f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -160,7 +160,8 @@ adjacent-zoom tiles publish before network waits; each arriving tile replaces its covered area, including healthy empty tiles. Grid tiles publish even while the independent observation request is pending or failed. Enable waits for one camera settle; camera acquisition uses moveEnd with a 600 ms debounce. The -undocumented UTFGrid endpoint has a schema guard; see `KNOWN-ISSUES.md`. +documented UTFGrid endpoint's unspecified cell records have a schema guard; +see `KNOWN-ISSUES.md`. Ambient cards use a fixed 96 px image width and a short creator name credit, with a bounded frame cache. New cards fill free screen regions; visible cards diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index ccd3ed906..3fdcbe67b 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -10,12 +10,14 @@ For the roadmap and open backlog, see the repository issue tracker. ## Open -### Wildlife grid is an undocumented iNaturalist endpoint +### Wildlife grid relies on the iNaturalist UTFGrid cell schema Status: Open (guarded fallback) -The operational `/v1/grid/{z}/{x}/{y}.grid.json` endpoint is not part of the -published v1 Swagger contract. Its 64×64 UTFGrid shape, keys and cell records +The `/v1/grid/{z}/{x}/{y}.grid.json` endpoint is documented in the v1 and v2 +API docs (“Grid Tiles UTFGrid”), but the documented response only defines the +UTFGrid `grid`, `keys` and `data` members; the per-cell records are not +specified. Its 64×64 UTFGrid shape, keys and cell records are validated in full at the allow-list proxy. A schema change or unavailable tile retains its last good cells for at most one hour; a cold unavailable grid uses sampled observations with the same badges and screen buckets, without diff --git a/src/layers/wildlifeSightings/docs.test.mjs b/src/layers/wildlifeSightings/docs.test.mjs index 3f84357d3..794bd62b5 100644 --- a/src/layers/wildlifeSightings/docs.test.mjs +++ b/src/layers/wildlifeSightings/docs.test.mjs @@ -109,3 +109,21 @@ test('Wildlife docs describe one source link, name declutter and globe glow limi assert.match(section, /5% radius margin/); assert.match(section, /add no per-frame work/); }); + +test('Wildlife docs cite the documented UTFGrid endpoint and the Acartia key request', () => { + for (const path of [ + 'docs/KNOWN-ISSUES.md', + 'DATA_SOURCES.md', + 'docs/CURRENT-STATE.md', + ]) { + const text = read(path); + assert.doesNotMatch(text, /undocumented (iNaturalist|UTFGrid) endpoint/i); + assert.doesNotMatch(text, /grid \(undocumented/i); + } + for (const path of ['docs/KNOWN-ISSUES.md', 'DATA_SOURCES.md']) + assert.match(read(path), /Grid Tiles UTFGrid/); + assert.match( + read('DATA_SOURCES.md'), + /create an account and generate an API key/, + ); +}); From 42fa974ee38a07293df64b70feabc45787737ed5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:27:19 -0800 Subject: [PATCH 7/8] fix(wildlife): keep each failed source's retry timing after a partial failure A partial failure reset the layer's cooldown, so a camera move retried at once. Acartia's error was discarded, an absent X-Upstream-Status hid the HTTP 503 (the proxy's daily-budget reply), and a grid schema error masked the observation 503. Each failed source now keeps its status and Retry-After, the HTTP status counts when no upstream status is sent, and the layer waits for the longest 429/5xx cooldown among them. The shown error is the one that explains the cooldown. --- docs/CURRENT-STATE.md | 6 +- .../wildlifeSightings/acquisition.test.mjs | 136 +++++++++++++++++- src/layers/wildlifeSightings/lifecycle.js | 79 ++++++---- src/layers/wildlifeSightings/source.js | 41 +++++- 4 files changed, 229 insertions(+), 33 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 96d85431f..5166e4a2a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -124,7 +124,11 @@ with retry timing; logs exclude request URLs and upstream exception text. A partial failure still draws what arrived: a failed iNaturalist request, or one failed half of an antimeridian view, keeps a good Acartia reply and the other half, retains that area's last iNaturalist rows and reports DEGRADED. -The update fails outright only when every requested source failed. +The update fails outright only when every requested source failed. Each failed +source keeps its HTTP status and `Retry-After` (the HTTP status counts when no +`X-Upstream-Status` is sent), and the next request waits out the longest 429 or +5xx cooldown among them. One update requests both sources, so the cooldown +holds the whole layer; a fully successful refresh clears it. `/api/acartia/sightings` adds Salish Sea marine mammals within 45.5–50.5° N, 126–121° W, cached ten minutes and stale for at most 24 h. A valid empty diff --git a/src/layers/wildlifeSightings/acquisition.test.mjs b/src/layers/wildlifeSightings/acquisition.test.mjs index ea1d6fc1a..d343ad9b0 100644 --- a/src/layers/wildlifeSightings/acquisition.test.mjs +++ b/src/layers/wildlifeSightings/acquisition.test.mjs @@ -833,10 +833,10 @@ test('an empty tile retires its old zoom cells while uncovered density survives assert.equal(h.state.gridTotal, 2); }); -test('only marked upstream failures create cooldown; local pacing, network and schema errors can retry on the next view', async () => { +test('only 429 and 5xx failures create cooldown; client, network and schema errors can retry on the next view', async () => { for (const error of [ - Object.assign(new Error('iNaturalist HTTP 503'), { - status: 503, + Object.assign(new Error('iNaturalist HTTP 400'), { + status: 400, upstreamStatus: 0, retryAfterSec: 30, }), @@ -873,6 +873,19 @@ test('only marked upstream failures create cooldown; local pacing, network and s 2, 'actual Retry-After is honored without imposing a 30s local floor', ); + // The proxy's daily-budget 503 has no upstream status. Its HTTP status and + // Retry-After still govern the next request. + const budget = harness({ + getSnapshot: async () => { + throw Object.assign(new Error('iNaturalist HTTP 503'), { + status: 503, + upstreamStatus: 0, + retryAfterSec: 3600, + }); + }, + }); + await budget.layer.update(); + assert.equal(budget.layer.getStats().retryInSec, 3600); }); test('stale cached tiles with local pacing metadata keep their display and do not enter cooldown', async () => { @@ -942,3 +955,120 @@ test('an explicit upstream Retry-After zero retries immediately while a missing } } }); + +const salishSea = { south: 47.5, west: -122.5, north: 47.7, east: -122.2 }; +const inatRow = { + stableId: 'inat:301', + origin: 'inaturalist', + lat: 47.6, + lon: -122.3, + observedAt: NOW - 3_600_000, + iconic: 'Mammalia', +}; +const acartiaRow = { + stableId: 'acartia:301', + origin: 'acartia', + lat: 47.6, + lon: -122.35, + observedAt: NOW - 3_600_000, + cetacean: true, + quality: 'research', + provider: { name: 'Provider', entity: null }, +}; +const emptyGrid = () => + Response.json({ cells: [], totalResults: 0, fetchedAt: NOW }); +const unavailable = (headers = {}) => + new Response('', { + status: 503, + headers: { 'Retry-After': '120', ...headers }, + }); + +// Each case runs the real source and lifecycle. A healthy source draws its +// rows while the failed source's Retry-After holds the next acquisition. +for (const [name, respond] of [ + [ + 'Acartia alone returns 503', + (url) => + url.startsWith('/api/acartia') + ? unavailable() + : url.startsWith('/api/inaturalist/grid?') + ? emptyGrid() + : Response.json({ + rows: [inatRow], + totalResults: 1, + fetchedAt: NOW, + }), + ], + [ + 'iNaturalist returns 503 without X-Upstream-Status', + (url) => + url.startsWith('/api/acartia') + ? Response.json({ rows: [acartiaRow], fetchedAt: NOW }) + : url.startsWith('/api/inaturalist/grid?') + ? emptyGrid() + : unavailable(), + ], + [ + 'iNaturalist returns 503 while every grid tile fails its schema', + (url) => + url.startsWith('/api/acartia') + ? Response.json({ rows: [acartiaRow], fetchedAt: NOW }) + : url.startsWith('/api/inaturalist/grid?') + ? Response.json({ cells: 'malformed' }) + : unavailable({ 'X-Upstream-Status': '503' }), + ], +]) { + test(`a partial failure keeps its Retry-After: ${name}`, async () => { + let healthy = false; + let requests = 0; + const source = createWildlifeSightingsSource({ + now: () => NOW, + fetchImpl: async (url) => { + if (!url.startsWith('/api/inaturalist/grid?')) requests++; + if (healthy) + return url.startsWith('/api/acartia') + ? Response.json({ rows: [acartiaRow], fetchedAt: NOW }) + : url.startsWith('/api/inaturalist/grid?') + ? emptyGrid() + : Response.json({ + rows: [inatRow], + totalResults: 1, + fetchedAt: NOW, + }); + return respond(url); + }, + }); + const h = harness(source); + h.bounds(salishSea); + h.state.enabled = false; + h.layer.enable(); + const first = h.layer.update(); + await h.advance(300); + await first; + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(h.state.renderedRows.length, 1, 'the healthy source draws'); + assert.ok(h.state.error, 'the failed source is reported'); + // The source clock stays at NOW; the layer settled 300 ms of camera time. + assert.equal(h.state.retryAt, NOW + 300 + 120_000); + assert.equal(h.layer.getStats().retryInSec, 120); + const attempted = requests; + h.bounds({ ...salishSea, north: 47.8 }); + h.moveEnd(); + assert.equal([...h.timers.values()][0]?.delay, 120_000); + await h.advance(119_999); + await h.layer.update(); + assert.equal( + requests, + attempted, + 'neither a camera move nor manager polling skips the cooldown', + ); + healthy = true; + await h.advance(1); + await new Promise((resolve) => setImmediate(resolve)); + assert.ok(requests > attempted, 'acquisition resumes at the deadline'); + assert.equal(h.state.error, null); + assert.equal(h.state.retryAt, 0, 'a fully successful refresh clears it'); + assert.equal(h.layer.getStats().retryInSec, undefined); + assert.equal(h.state.renderedRows.length, 2); + }); +} diff --git a/src/layers/wildlifeSightings/lifecycle.js b/src/layers/wildlifeSightings/lifecycle.js index 6da425c7a..3b2a2999a 100644 --- a/src/layers/wildlifeSightings/lifecycle.js +++ b/src/layers/wildlifeSightings/lifecycle.js @@ -1,6 +1,9 @@ import { msg } from '../../i18n/marker.js'; import { getWildlifeHeightBand, rowInWildlifeBoxes } from './model.js'; -import { combineWildlifeGridSnapshots } from './source.js'; +import { + combineWildlifeGridSnapshots, + wildlifeFailureRetryable, +} from './source.js'; import { wildlifeMeasure } from './performance.js'; import { isRecentWildlifeSighting } from './records.js'; import { @@ -92,25 +95,26 @@ export function createLifecycle({ state, services, parts }) { state.pendingParams = null; } - function applyRetry(error, retryAfterSec = null) { - // Only upstream failures impose cooldown; network/schema errors retry on pan. - const status = - error?.upstreamStatus ?? - error?.status ?? - Number(/HTTP (\d{3})/.exec(error?.message || '')?.[1]); - const retryDelays = [error?.retryAfterSec, retryAfterSec].filter( - (value) => Number.isFinite(value) && value >= 0, - ); - const retryFromSnapshot = - Number.isFinite(retryAfterSec) && retryAfterSec >= 0; - if (!(status === 429 || status >= 500) && !retryFromSnapshot) { + function applyRetry(errors, retryAfterSec = null) { + // Only upstream failures impose cooldown; network/schema errors retry on + // pan. Several sources can fail together: wait for the longest of them. + const failures = [errors].flat().filter(wildlifeFailureRetryable); + const snapshotDelay = + Number.isFinite(retryAfterSec) && retryAfterSec >= 0 + ? retryAfterSec * 1000 + : null; + if (!failures.length && snapshotDelay === null) { state.failures = 0; state.retryAt = 0; return; } - const backoff = retryDelays.length - ? Math.max(...retryDelays) * 1000 - : retryDelayMs(state.failures || 0); + const delays = failures.map((error) => + Number.isFinite(error.retryAfterSec) && error.retryAfterSec >= 0 + ? error.retryAfterSec * 1000 + : retryDelayMs(state.failures || 0), + ); + if (snapshotDelay !== null) delays.push(snapshotDelay); + const backoff = Math.max(...delays); state.failures = backoff; state.retryAt = services.now() + backoff; } @@ -404,16 +408,39 @@ export function createLifecycle({ state, services, parts }) { }, ) : null; - const sourceError = - gridError || - staleError || - (snapshot.sources?.inaturalist?.ok === false - ? snapshot.sources.inaturalist.error || + const inat = snapshot.sources?.inaturalist; + const acartia = snapshot.sources?.acartia; + // Keep each failed source's status and Retry-After; the displayed error + // is the one that explains the cooldown. + const sourceErrors = [ + gridError, + staleError, + inat?.ok === false + ? inat.error || new Error(msg('Wildlife sightings source unavailable')) - : null) || - (snapshot.sources?.acartia?.ok === false - ? new Error(msg('Acartia feed unavailable')) - : null); + : null, + acartia?.ok === false + ? Object.assign(new Error(msg('Acartia feed unavailable')), { + ...(acartia.error?.status + ? { status: acartia.error.status } + : {}), + ...(acartia.error?.upstreamStatus + ? { upstreamStatus: acartia.error.upstreamStatus } + : {}), + ...(Number.isFinite(acartia.error?.retryAfterSec) + ? { retryAfterSec: acartia.error.retryAfterSec } + : {}), + }) + : null, + ].filter(Boolean); + const sourceError = + sourceErrors.find( + (error) => + wildlifeFailureRetryable(error) || + Number.isFinite(error.retryAfterSec), + ) || + sourceErrors[0] || + null; state.stale = !sourceError && (snapshot.stale === true || grid?.stale === true); const fetchedAt = [snapshot.fetchedAt, grid?.fetchedAt].filter( @@ -438,7 +465,7 @@ export function createLifecycle({ state, services, parts }) { if (sourceError) { failParams(params); applyRetry( - sourceError, + sourceErrors, retryAfter.length ? Math.max(...retryAfter) : null, ); } else { diff --git a/src/layers/wildlifeSightings/source.js b/src/layers/wildlifeSightings/source.js index 8bd41bf1d..aa218f34e 100644 --- a/src/layers/wildlifeSightings/source.js +++ b/src/layers/wildlifeSightings/source.js @@ -102,6 +102,36 @@ function validProjectedRows(rows, origin) { ); } +/** Return the status a failure reports. The proxy marks upstream failures + * with X-Upstream-Status; without it the HTTP status is the signal. */ +function wildlifeFailureStatus(error) { + return ( + error?.upstreamStatus || + error?.status || + Number(/HTTP (\d{3})/.exec(error?.message || '')?.[1]) || + 0 + ); +} + +/** Whether a failure asks clients to wait before the next request. */ +export function wildlifeFailureRetryable(error) { + const status = wildlifeFailureStatus(error); + return status === 429 || status >= 500; +} + +/** Prefer the failure that carries retry timing, then the longest wait. */ +export function preferredWildlifeFailure(errors) { + const failures = errors.filter(Boolean); + const retryable = failures.filter(wildlifeFailureRetryable); + if (!retryable.length) return failures[0] || null; + return retryable.reduce((best, error) => + (Number.isFinite(error.retryAfterSec) ? error.retryAfterSec : -1) > + (Number.isFinite(best.retryAfterSec) ? best.retryAfterSec : -1) + ? error + : best, + ); +} + /** Merge occupied cells incrementally; neighboring UTFGrid hit tiles can * report the same geographic cell, so source ids remain the dedupe key. */ export function combineWildlifeGridSnapshots(snapshots, error = null) { @@ -421,7 +451,7 @@ export function createWildlifeSightingsSource({ ), ); signal?.throwIfAborted(); - const failure = failures.find(Boolean); + const failure = preferredWildlifeFailure(failures); if (!snapshots.some(Boolean) && failure) throw failure; return combineWildlifeGridSnapshots(snapshots.filter(Boolean), failure); } @@ -494,11 +524,15 @@ export function createWildlifeSightingsSource({ }); // Fail outright only when nothing succeeded; otherwise draw what arrived // and report the failed source in its per-source status. + const acartiaError = + useAcartia && acartiaResult.status === 'rejected' + ? acartiaResult.reason + : null; if ( !inatSnapshots.length && (!useAcartia || acartiaResult.status !== 'fulfilled') ) - throw inatError; + throw preferredWildlifeFailure([inatError, acartiaError]); const inatRows = inatSnapshots.flatMap((snapshot) => snapshot.rows); const acartiaSnapshot = acartiaResult.status === 'fulfilled' ? acartiaResult.value : null; @@ -553,12 +587,13 @@ export function createWildlifeSightingsSource({ ...(inatError ? { error: inatError, failedBoxes } : {}), }, acartia: { - ok: !useAcartia || acartiaResult.status === 'fulfilled', + ok: !acartiaError, stale: acartiaStale, fetchedAt: Number.isFinite(acartiaSnapshot?.fetchedAt) ? acartiaSnapshot.fetchedAt : null, count: drawnAcartia, + ...(acartiaError ? { error: acartiaError } : {}), }, }, }; From dac9b5627d629655304f11eddd5e45f861f512b9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:42:45 -0800 Subject: [PATCH 8/8] fix(wildlife): wait for the longest effective delay among every failed request One error was chosen before each failure's delay was known. An antimeridian box 400 hid the other box's 503 Retry-After; a grid tile 400 with Retry-After set a cooldown through the snapshot metadata; a short Retry-After on one tile undercut a sibling 5xx's default backoff; and a huge Retry-After made the deadline Infinity. Every failed request now keeps its status and Retry-After. One function gives each failure's delay: 4xx other than 429 none, 429 and 5xx their Retry-After or the default backoff. The layer waits for the longest, and shows the failure that set it. Retry-After is capped at one day, the proxy's own daily-budget deadline, and non-finite values are ignored. Each publish of one acquisition derives the default backoff from the backoff before it started, so repeated tile publishes no longer double it. --- .../wildlifeSightings/acquisition.test.mjs | 95 +++++++++++++++++++ src/layers/wildlifeSightings/lifecycle.js | 74 ++++++++------- src/layers/wildlifeSightings/policy.js | 3 + src/layers/wildlifeSightings/source.js | 89 +++++++++++++---- 4 files changed, 209 insertions(+), 52 deletions(-) diff --git a/src/layers/wildlifeSightings/acquisition.test.mjs b/src/layers/wildlifeSightings/acquisition.test.mjs index d343ad9b0..b4ac47813 100644 --- a/src/layers/wildlifeSightings/acquisition.test.mjs +++ b/src/layers/wildlifeSightings/acquisition.test.mjs @@ -1072,3 +1072,98 @@ for (const [name, respond] of [ assert.equal(h.state.renderedRows.length, 2); }); } + +// Every failed request keeps its own timing. The layer waits for the longest +// effective delay: 4xx other than 429 never waits, 429/5xx wait for their +// Retry-After or the default backoff. The shown error explains that wait. +async function settleThroughSource(fetchImpl, bounds = salishSea) { + const h = harness( + createWildlifeSightingsSource({ now: () => NOW, fetchImpl }), + ); + h.bounds(bounds); + h.state.enabled = false; + h.layer.enable(); + const first = h.layer.update(); + await h.advance(300); + await first; + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + return h; +} +const tileX = (url) => + Number(new URL(url, 'https://example.test').searchParams.get('x')); +const observations = () => + Response.json({ rows: [inatRow], totalResults: 1, fetchedAt: NOW }); + +test('an antimeridian box 400 never hides the other box 503 Retry-After', async () => { + for (const westFails of [true, false]) { + const h = await settleThroughSource( + async (url) => { + if (url.startsWith('/api/inaturalist/grid?')) return emptyGrid(); + const west = + Number( + new URL(url, 'https://example.test').searchParams.get('west'), + ) > 0; + return west === westFails + ? new Response('', { status: 400 }) + : unavailable(); + }, + { south: 10, west: 179, north: 12, east: -179 }, + ); + assert.equal(h.state.error, 'iNaturalist HTTP 503'); + assert.equal(h.state.retryAt, NOW + 300 + 120_000); + assert.equal(h.layer.getStats().retryInSec, 120); + } +}); + +test('a grid tile 400 with Retry-After beside successful tiles creates no cooldown', async () => { + let tiles = new Set(); + const h = await settleThroughSource(async (url) => { + if (!url.startsWith('/api/inaturalist/grid?')) return observations(); + tiles.add(tileX(url)); + return tileX(url) === Math.min(...tiles) && tiles.size === 1 + ? new Response('', { status: 400, headers: { 'Retry-After': '120' } }) + : emptyGrid(); + }); + assert.ok(tiles.size > 1, 'the view spans several grid tiles'); + assert.equal(h.state.error, 'iNaturalist HTTP 400'); + assert.equal(h.state.retryAt, 0); + assert.equal(h.layer.getStats().retryInSec, undefined); +}); + +test('a short grid Retry-After never undercuts a sibling 5xx default backoff', async () => { + let failed = 0; + const h = await settleThroughSource(async (url) => { + if (!url.startsWith('/api/inaturalist/grid?')) return observations(); + failed++; + if (failed === 1) + return new Response('', { status: 503, headers: { 'Retry-After': '2' } }); + if (failed === 2) return new Response('', { status: 502 }); + return emptyGrid(); + }); + assert.ok(failed >= 2); + assert.equal(h.state.error, 'iNaturalist HTTP 502'); + assert.equal(h.state.retryAt, NOW + 300 + 30_000); + assert.equal(h.layer.getStats().retryInSec, 30); +}); + +test('an enormous Retry-After is capped at one day and a non-finite one uses the default backoff', async () => { + for (const [header, delay] of [ + ['1' + '0'.repeat(306), 86_400_000], + ['1' + '0'.repeat(400), 30_000], + ]) { + const h = await settleThroughSource(async (url) => + url.startsWith('/api/inaturalist/grid?') + ? emptyGrid() + : url.startsWith('/api/acartia') + ? Response.json({ rows: [], fetchedAt: NOW }) + : new Response('', { + status: 503, + headers: { 'Retry-After': header }, + }), + ); + assert.equal(h.state.error, 'iNaturalist HTTP 503'); + assert.ok(Number.isFinite(h.state.retryAt)); + assert.equal(h.state.retryAt, NOW + 300 + delay, header.length); + } +}); diff --git a/src/layers/wildlifeSightings/lifecycle.js b/src/layers/wildlifeSightings/lifecycle.js index 3b2a2999a..0fe430fbe 100644 --- a/src/layers/wildlifeSightings/lifecycle.js +++ b/src/layers/wildlifeSightings/lifecycle.js @@ -2,7 +2,10 @@ import { msg } from '../../i18n/marker.js'; import { getWildlifeHeightBand, rowInWildlifeBoxes } from './model.js'; import { combineWildlifeGridSnapshots, - wildlifeFailureRetryable, + preferredWildlifeFailure, + wildlifeFailureDelayMs, + wildlifeFailures, + wildlifeRetryAfterMs, } from './source.js'; import { wildlifeMeasure } from './performance.js'; import { isRecentWildlifeSighting } from './records.js'; @@ -12,11 +15,7 @@ import { wildlifeGridTiles, wildlifeGridCellId, } from './gridViewport.js'; -import { - wildlifeViewportBoxes, - snapWildlifeBox, - retryDelayMs, -} from './viewport.js'; +import { wildlifeViewportBoxes, snapWildlifeBox } from './viewport.js'; import { WILDLIFE_DEBOUNCE_MS, WILDLIFE_MAX_ROWS, @@ -95,25 +94,25 @@ export function createLifecycle({ state, services, parts }) { state.pendingParams = null; } - function applyRetry(errors, retryAfterSec = null) { - // Only upstream failures impose cooldown; network/schema errors retry on - // pan. Several sources can fail together: wait for the longest of them. - const failures = [errors].flat().filter(wildlifeFailureRetryable); - const snapshotDelay = - Number.isFinite(retryAfterSec) && retryAfterSec >= 0 - ? retryAfterSec * 1000 - : null; - if (!failures.length && snapshotDelay === null) { + function applyRetry( + errors, + retryAfterSec = null, + previousMs = state.failures || 0, + ) { + // Only 429/5xx failures impose cooldown; client, network and schema + // errors retry on pan. Several requests can fail together: wait for the + // longest effective delay among them. + const delays = [ + ...wildlifeFailures(errors).map((error) => + wildlifeFailureDelayMs(error, previousMs), + ), + wildlifeRetryAfterMs(retryAfterSec), + ].filter((delay) => delay !== null); + if (!delays.length) { state.failures = 0; state.retryAt = 0; return; } - const delays = failures.map((error) => - Number.isFinite(error.retryAfterSec) && error.retryAfterSec >= 0 - ? error.retryAfterSec * 1000 - : retryDelayMs(state.failures || 0), - ); - if (snapshotDelay !== null) delays.push(snapshotDelay); const backoff = Math.max(...delays); state.failures = backoff; state.retryAt = services.now() + backoff; @@ -282,6 +281,7 @@ export function createLifecycle({ state, services, parts }) { request, signal, epoch, + previousFailures, replaceRows = true, }) { const current = currentQuery(); @@ -398,10 +398,14 @@ export function createLifecycle({ state, services, parts }) { value.upstreamStatus === 429 || value.upstreamStatus >= 500), ); + // The filter above admits only 429/5xx or an unmarked stale answer. const staleError = failedStale.length ? Object.assign( new Error(msg('Wildlife sightings source unavailable')), { + upstreamStatus: Math.max( + ...failedStale.map((value) => value.upstreamStatus || 503), + ), retryAfterSec: Math.max( ...failedStale.map((value) => value.retryAfterSec), ), @@ -410,8 +414,8 @@ export function createLifecycle({ state, services, parts }) { : null; const inat = snapshot.sources?.inaturalist; const acartia = snapshot.sources?.acartia; - // Keep each failed source's status and Retry-After; the displayed error - // is the one that explains the cooldown. + // Keep each failed request's status and Retry-After; the displayed + // error is the one whose effective delay sets the cooldown. const sourceErrors = [ gridError, staleError, @@ -433,14 +437,10 @@ export function createLifecycle({ state, services, parts }) { }) : null, ].filter(Boolean); - const sourceError = - sourceErrors.find( - (error) => - wildlifeFailureRetryable(error) || - Number.isFinite(error.retryAfterSec), - ) || - sourceErrors[0] || - null; + const sourceError = preferredWildlifeFailure( + sourceErrors, + previousFailures, + ); state.stale = !sourceError && (snapshot.stale === true || grid?.stale === true); const fetchedAt = [snapshot.fetchedAt, grid?.fetchedAt].filter( @@ -467,12 +467,13 @@ export function createLifecycle({ state, services, parts }) { applyRetry( sourceErrors, retryAfter.length ? Math.max(...retryAfter) : null, + previousFailures, ); } else { state.pendingParams = null; state.failedParams = null; state.retryAt = retryAfter.length - ? services.now() + Math.max(...retryAfter) * 1000 + ? services.now() + wildlifeRetryAfterMs(Math.max(...retryAfter)) : 0; state.failures = 0; } @@ -632,6 +633,9 @@ export function createLifecycle({ state, services, parts }) { state.request?.abort(); const request = new AbortController(); const requestLifetime = lifetimeEpoch; + // One acquisition publishes several times (tiles, rows, final grid). + // Each derives its default backoff from the backoff before it started. + const previousFailures = state.failures || 0; const supersededResult = () => state.enabled && !state.destroyed && @@ -699,6 +703,7 @@ export function createLifecycle({ state, services, parts }) { request, signal, epoch: requestLifetime, + previousFailures, }); } function finishTiles() { @@ -804,11 +809,12 @@ export function createLifecycle({ state, services, parts }) { continueTiles(); state.stale = false; state.error = - error?.message || msg('Wildlife sightings source unavailable'); + preferredWildlifeFailure(error, previousFailures)?.message || + msg('Wildlife sightings source unavailable'); failParams(params); state.lastQueryKey = null; requestQueryKey = null; - applyRetry(error); + applyRetry(error, null, previousFailures); console.warn('[Data:WildlifeSightings] Update failed:', state.error); return true; } finally { diff --git a/src/layers/wildlifeSightings/policy.js b/src/layers/wildlifeSightings/policy.js index 4af9d7d17..f1b6df277 100644 --- a/src/layers/wildlifeSightings/policy.js +++ b/src/layers/wildlifeSightings/policy.js @@ -74,6 +74,9 @@ export { WILDLIFE_MAX_VIEW_DEGREES } from './bounds.js'; export const WILDLIFE_VIEW_PADDING = 0.3; export const WILDLIFE_RETRY_MIN_MS = 30_000; export const WILDLIFE_RETRY_MAX_MS = 240_000; +// Cap a Retry-After so one reply can never stall refresh forever. The proxy's +// daily-budget reply waits until the next UTC midnight, so allow one day. +export const WILDLIFE_RETRY_AFTER_MAX_SEC = 86_400; // Faster polling would reread the proxy's five-minute cache. export const WILDLIFE_UPDATE_INTERVAL_MS = 5 * 60_000; diff --git a/src/layers/wildlifeSightings/source.js b/src/layers/wildlifeSightings/source.js index aa218f34e..e2e3f1b80 100644 --- a/src/layers/wildlifeSightings/source.js +++ b/src/layers/wildlifeSightings/source.js @@ -1,8 +1,12 @@ import { readResponseJsonCapped } from '../../sources/httpBody.js'; import { mergeSightings } from './records.js'; import { wildlifeMeasure } from './performance.js'; -import { ACARTIA_GUARD_BOX, TAXA_PRESETS } from './policy.js'; -import { boxIntersects } from './viewport.js'; +import { + ACARTIA_GUARD_BOX, + TAXA_PRESETS, + WILDLIFE_RETRY_AFTER_MAX_SEC, +} from './policy.js'; +import { boxIntersects, retryDelayMs } from './viewport.js'; import { WILDLIFE_GRID_MAX_ZOOM, WILDLIFE_GRID_TILE_CAP, @@ -72,14 +76,19 @@ function retryAfterSeconds(value, nowMs) { const text = value.trim(); if (/^\d+(?:\.\d+)?$/.test(text)) { const seconds = Number(text); - return Number.isFinite(seconds) ? Math.ceil(seconds) : null; + return Number.isFinite(seconds) + ? Math.min(WILDLIFE_RETRY_AFTER_MAX_SEC, Math.ceil(seconds)) + : null; } // Reject malformed numeric values rather than letting Date.parse interpret // them as calendar dates. HTTP dates include a named weekday or month. if (!/[a-z]/i.test(text)) return null; const deadline = Date.parse(text); return Number.isFinite(deadline) && Number.isFinite(nowMs) - ? Math.max(0, Math.ceil((deadline - nowMs) / 1000)) + ? Math.min( + WILDLIFE_RETRY_AFTER_MAX_SEC, + Math.max(0, Math.ceil((deadline - nowMs) / 1000)), + ) : null; } @@ -119,17 +128,56 @@ export function wildlifeFailureRetryable(error) { return status === 429 || status >= 500; } -/** Prefer the failure that carries retry timing, then the longest wait. */ -export function preferredWildlifeFailure(errors) { - const failures = errors.filter(Boolean); - const retryable = failures.filter(wildlifeFailureRetryable); - if (!retryable.length) return failures[0] || null; - return retryable.reduce((best, error) => - (Number.isFinite(error.retryAfterSec) ? error.retryAfterSec : -1) > - (Number.isFinite(best.retryAfterSec) ? best.retryAfterSec : -1) - ? error - : best, - ); +/** Return a Retry-After in milliseconds, capped at one day, or null when + * it is missing, negative or not finite. */ +export function wildlifeRetryAfterMs(seconds) { + return Number.isFinite(seconds) && seconds >= 0 + ? Math.min(WILDLIFE_RETRY_AFTER_MAX_SEC, seconds) * 1000 + : null; +} + +/** Return how long a failure asks the layer to wait, or null for none: + * 429 and 5xx wait for their Retry-After, else the default backoff after + * `previousMs`; other failures may retry on the next view. */ +export function wildlifeFailureDelayMs(error, previousMs = 0) { + if (!error || !wildlifeFailureRetryable(error)) return null; + return wildlifeRetryAfterMs(error.retryAfterSec) ?? retryDelayMs(previousMs); +} + +/** Flatten failures, expanding a group into every request that failed. */ +export function wildlifeFailures(errors) { + return [errors] + .flat() + .filter(Boolean) + .flatMap((error) => + Array.isArray(error.failures) && error.failures.length + ? error.failures + : [error], + ); +} + +/** Return the failure with the longest effective delay (the first failure + * when none waits), carrying every failure so the caller can recompute. */ +export function preferredWildlifeFailure(errors, previousMs = 0) { + const failures = wildlifeFailures(errors); + if (!failures.length) return null; + let chosen = failures[0]; + let longest = wildlifeFailureDelayMs(chosen, previousMs) ?? -1; + for (const error of failures.slice(1)) { + const delay = wildlifeFailureDelayMs(error, previousMs) ?? -1; + if (delay > longest) [chosen, longest] = [error, delay]; + } + if (failures.length === 1) return chosen; + // Grid errors are shared between consumers of one tile request; describe + // the group with a new error rather than mutating any of them. + return Object.assign(new Error(chosen.message, { cause: chosen }), { + ...(chosen.status ? { status: chosen.status } : {}), + ...(chosen.upstreamStatus ? { upstreamStatus: chosen.upstreamStatus } : {}), + ...(Number.isFinite(chosen.retryAfterSec) + ? { retryAfterSec: chosen.retryAfterSec } + : {}), + failures, + }); } /** Merge occupied cells incrementally; neighboring UTFGrid hit tiles can @@ -145,9 +193,12 @@ export function combineWildlifeGridSnapshots(snapshots, error = null) { const totalResults = cells.reduce((sum, cell) => sum + cell.cellCount, 0); if (!Number.isSafeInteger(totalResults)) throw new Error('Malformed iNaturalist grid response'); + // A failure's Retry-After counts only when its status asks for a wait. const retryAfter = [ ...snapshots.map((snapshot) => snapshot.retryAfterSec), - error?.retryAfterSec, + ...wildlifeFailures(error) + .filter(wildlifeFailureRetryable) + .map((failure) => failure.retryAfterSec), ].filter(Number.isFinite); const stale = snapshots.some((snapshot) => snapshot.stale === true); return { @@ -514,14 +565,16 @@ export function createWildlifeSightingsSource({ signal?.throwIfAborted(); const inatSnapshots = []; const failedBoxes = []; - let inatError = null; + const inatErrors = []; inatResult.value.forEach((result, index) => { if (result.status === 'fulfilled') inatSnapshots.push(result.value); else { failedBoxes.push(requestBoxes[index]); - inatError ??= result.reason; + inatErrors.push(result.reason); } }); + // Keep every failed box; the layer waits for the longest of them. + const inatError = preferredWildlifeFailure(inatErrors); // Fail outright only when nothing succeeded; otherwise draw what arrived // and report the failed source in its per-source status. const acartiaError =