mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 03:44:00 +00:00
Merge pull request #1591 from lyehe/pr/wildlife-sightings
feat: Wildlife Sightings layer from iNaturalist and Acartia
This commit is contained in:
120 files changed
+21843
-285
No files matched your search
@@ -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 (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.
|
||||
|
||||
@@ -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 | 🟢 |
|
||||
|
||||
+1
-1
@@ -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 `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights`, `/api/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 `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights`, `/api/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 `<style>` elements.
|
||||
|
||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
- Add Wildlife Sightings in the Wildlife layer group, off by default, using
|
||||
keyless iNaturalist observations within 48 h (whales 7 d) and Acartia Salish
|
||||
Sea marine mammals. CONFIRMED and taxa filters share via token `9`.
|
||||
A noninteractive density glow accompanies real-sighting taxon badges,
|
||||
decluttered species labels and credited ambient photos. Selection stays
|
||||
pinned across camera and sample changes and opens an anchored photo card
|
||||
with a contained image, creator/licence and one iNaturalist source link.
|
||||
Obscured and Acartia sightings use text-only cards; private records and
|
||||
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, #1591)
|
||||
@@ -151,6 +151,106 @@ across the antimeridian, so they cannot use the strict ±180 check as-is, and
|
||||
`src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own
|
||||
implementations.
|
||||
|
||||
## Wildlife Sightings — iNaturalist and Acartia
|
||||
|
||||
**Wildlife Sightings** (`wildlife-sightings`, share-link token `9`) is off by
|
||||
default in the Wildlife group between Events and Weather. CONFIRMED and six
|
||||
taxa presets round-trip in share links. Voice is off for this layer for now;
|
||||
it is outside analyst queries and DETECT. Community observations are not a
|
||||
census; private records are excluded.
|
||||
|
||||
`/api/inaturalist/observations` requests verifiable wildlife in view within
|
||||
48 h (whales 7 d). Recency uses observation time: the `d1` date filter and a
|
||||
strict rolling cutoff. Date-only rows qualify only when their day starts
|
||||
within the window. Close views use a cached random sample of at most 200 rows;
|
||||
global samples use newest observations first. Observation caches last five
|
||||
minutes (global ten minutes), with stale fallback for at most one hour.
|
||||
|
||||
Observations and grid tiles share a paced priority queue: a four-call burst,
|
||||
then one request per second, at most four in flight, ≤60/minute and a persisted
|
||||
8,000/day budget. Superseded consumers leave before dispatch. The recovered
|
||||
queue tail isolates failures; ordinary grid failures do not fail observations.
|
||||
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. 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
|
||||
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.
|
||||
Observer identity is retained only for photo attribution.
|
||||
|
||||
`/api/inaturalist/grid` supplies a dedicated, nonpickable billboard density glow,
|
||||
with a view-relative density ramp from cool teal to warm amber. Overlapping
|
||||
Gaussian splats span four logical cell widths; mono and CRT styles follow map
|
||||
and cockpit vision changes. Empty snapshots paint nothing. Full footprints
|
||||
stay inside the globe limb with a 5% radius margin; opacity fades from full at
|
||||
3,000 km to zero at 8,000 km camera altitude. Culling and cached coordinates
|
||||
add no per-frame work and leave sighting counts intact.
|
||||
|
||||
Only real sampled observation rows produce taxon badges at their public
|
||||
positions. The badges use 28 px screen buckets, a 22 px overlap check and a
|
||||
5,000-observation cap. Tinted circles have a white Material Symbols glyph and
|
||||
1.5 px dark outline, remaining 12–22 px at all distances, including selection.
|
||||
Glyphs: birds `raven`, mammals `pets`, reptiles `egg_alt`, amphibians
|
||||
`water_drop`, fishes `set_meal`, insects `bug_report`, arachnids `pest_control`,
|
||||
plants `eco`, fungi `umbrella`, other animals `category`, unknown `question_mark`.
|
||||
A plain circle displays while the icon font loads. Row counts read
|
||||
“{n} sightings in view”, using visible grid totals when available.
|
||||
|
||||
Views request at most six tiles, coarsening zoom for complete coverage. Server
|
||||
and client keep bounded 400-tile caches for 15 minutes. Cached, stale and
|
||||
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
|
||||
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
|
||||
survive a random sample refresh until they leave the view or observation
|
||||
window. The 110/125 km show/hide thresholds provide hysteresis; cards scale
|
||||
and fade gradually through 120 km. Only painted photos suppress their own
|
||||
badge and label. Labels yield to painted cards, then compete by ambient score;
|
||||
repeated translated names within 80 screen pixels yield to higher-priority
|
||||
content. At most 150 labels enter the host. Titles prefer a word boundary for
|
||||
ellipses; accessible names remain complete. Selected thumbnails or names use
|
||||
the shared selected chrome and paint lane, with no canvas source link.
|
||||
|
||||
Clicking a badge, label or photo flies to its supplied position and opens a
|
||||
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,
|
||||
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
|
||||
an idle frame listener; shell teardown also removes its host and subscription.
|
||||
|
||||
Attribution appears in the layer row and Data attribution popover:
|
||||
“iNaturalist contributors · licences per record”, plus Acartia when drawn.
|
||||
Disable/destroy releases the glow collection, texture atlas, timers, queues and
|
||||
style listeners. Synthetic fixtures cover privacy, acquisition, rendering,
|
||||
selection and card placement. Deterministic operation checks run in `npm test`;
|
||||
wall-clock budgets run in `npm run test:perf`. All 11 locales are supported.
|
||||
|
||||
|
||||
## Prohibited & Restricted Airspace — FAA special-use airspace
|
||||
|
||||
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
|
||||
@@ -1597,6 +1697,8 @@ are owned by a renderer-free panel component. The layer manager supplies current
|
||||
snapshots, lifecycle actions and row descriptors. Remount and teardown remove
|
||||
listeners and row subscriptions; obsolete completions do not repaint old rows.
|
||||
The clear control presents busy state while its existing action owns the transaction.
|
||||
The Wildlife group follows Events and precedes Weather; Wildlife Sightings
|
||||
provides its confirmed toggle, taxa presets and fixed-order, nonempty legend.
|
||||
|
||||
## Sky background
|
||||
|
||||
@@ -4268,6 +4370,7 @@ its criteria cannot be silently ignored.
|
||||
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
|
||||
| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) |
|
||||
|
||||
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
|
||||
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
|
||||
|
||||
@@ -10,6 +10,24 @@ For the roadmap and open backlog, see the repository issue tracker.
|
||||
|
||||
## Open
|
||||
|
||||
### Wildlife grid relies on the iNaturalist UTFGrid cell schema
|
||||
|
||||
Status: Open (guarded fallback)
|
||||
|
||||
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
|
||||
claiming complete worldwide coverage. Tests use
|
||||
synthetic fixtures with the locally inspected tile's exact structure. Retest
|
||||
and update the schema guard before accepting an upstream shape change. Grid
|
||||
and observation calls share the paced queue, provider budget and 429 cooldown;
|
||||
ordinary tile failures do not block observation queries.
|
||||
|
||||
|
||||
### Street traffic can be slow/uneven when panning across dense city blocks
|
||||
Status: Open (partially mitigated)
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||
names a glyph this list is missing, because a missing glyph does not draw
|
||||
a box — the ligature never forms and the element renders the literal word. -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,pest_control,pets,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||
|
||||
+5
-2
@@ -2,7 +2,7 @@
|
||||
"name": "gods-eye-view",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
|
||||
"description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bilawal Sidhu",
|
||||
@@ -47,6 +47,7 @@
|
||||
"build:panel": "node scripts/build-panel.mjs",
|
||||
"preview": "vite preview",
|
||||
"test": "node scripts/run-unit-tests.mjs",
|
||||
"test:perf": "node scripts/run-perf-tests.mjs",
|
||||
"test:track": "node scripts/track-regression.mjs",
|
||||
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
|
||||
"qa:street-level": "node scripts/qa-street-level.mjs",
|
||||
@@ -292,6 +293,8 @@
|
||||
"./tools/panel": "./src/tools/panel.js",
|
||||
"./tools/services": "./src/tools/services.js",
|
||||
"./view": "./src/view/index.js",
|
||||
"./agent/world-packet": "./src/agent/worldPacket.js"
|
||||
"./agent/world-packet": "./src/agent/worldPacket.js",
|
||||
"./layers/wildlife-sightings": "./src/layers/wildlifeSightings/index.js",
|
||||
"./layers/wildlife-sightings/source": "./src/layers/wildlifeSightings/source.js"
|
||||
}
|
||||
}
|
||||
@@ -1270,5 +1270,89 @@
|
||||
"src/voice/geminiTextChat.js",
|
||||
"src/voice/panelLayout.js",
|
||||
"src/voice/textChat.js",
|
||||
"src/voice/textProtocol.js"
|
||||
"src/voice/textProtocol.js",
|
||||
"src/app/sourceComposition.js",
|
||||
"src/app/sourceComposition.test.mjs",
|
||||
"src/data/dataCredits.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerState.test.mjs",
|
||||
"src/data/spriteOrder.js",
|
||||
"src/i18n/locales/ar.json",
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/i18n/same-as-english.json",
|
||||
"src/navigationPolicy.js",
|
||||
"src/overlays/worldOverlayDraw.js",
|
||||
"src/overlays/worldOverlayDraw.test.mjs",
|
||||
"src/sources/rateLimit.js",
|
||||
"src/ui/layerNames.js",
|
||||
"src/ui/styles/cyber.css",
|
||||
"src/voice/layerManifest.js",
|
||||
"src/worldFocus.js",
|
||||
"src/worldFocus.test.mjs",
|
||||
"server/providers/acartia.js",
|
||||
"server/providers/acartia.test.mjs",
|
||||
"server/providers/common/userAgent.js",
|
||||
"server/providers/inaturalist/client.js",
|
||||
"server/providers/inaturalist/observations.js",
|
||||
"server/providers/inaturalist/observations.test.mjs",
|
||||
"src/app/layers/wildlifeSightings.js",
|
||||
"src/layers/wildlifeSightings/cards.js",
|
||||
"src/layers/wildlifeSightings/cards.test.mjs",
|
||||
"src/layers/wildlifeSightings/controls.js",
|
||||
"src/layers/wildlifeSightings/docs.test.mjs",
|
||||
"src/layers/wildlifeSightings/index.js",
|
||||
"src/layers/wildlifeSightings/lifecycle.js",
|
||||
"src/layers/wildlifeSightings/model.js",
|
||||
"src/layers/wildlifeSightings/model.test.mjs",
|
||||
"src/layers/wildlifeSightings/ownership.test.mjs",
|
||||
"src/layers/wildlifeSightings/photos.js",
|
||||
"src/layers/wildlifeSightings/photos.test.mjs",
|
||||
"src/layers/wildlifeSightings/policy.js",
|
||||
"src/layers/wildlifeSightings/records.js",
|
||||
"src/layers/wildlifeSightings/records.test.mjs",
|
||||
"src/layers/wildlifeSightings/rendering.js",
|
||||
"src/layers/wildlifeSightings/selection.js",
|
||||
"src/layers/wildlifeSightings/source.js",
|
||||
"src/layers/wildlifeSightings/source.test.mjs",
|
||||
"src/layers/wildlifeSightings/viewport.js",
|
||||
"src/layers/wildlifeSightings/viewport.test.mjs",
|
||||
"src/sources/rateLimit.test.mjs",
|
||||
"src/layers/wildlifeSightings/bounds.js",
|
||||
"src/layers/wildlifeSightings/acartiaTaxa.js",
|
||||
"src/layers/wildlifeSightings/acquisition.test.mjs",
|
||||
"server/providers/inaturalist/client.test.mjs",
|
||||
"src/layers/wildlifeSightings/acartiaTaxa.test.mjs",
|
||||
"src/ui/wildlifeSightingCard.test.mjs",
|
||||
"src/overlays/wildlifeSightingOverlay.test.mjs",
|
||||
"scripts/run-perf-tests.mjs",
|
||||
"src/perfTestRunner.test.mjs",
|
||||
"src/testSupport/wildlifeBillboardModel.mjs",
|
||||
"src/layers/wildlifeSightings/gridViewport.test.mjs",
|
||||
"src/layers/wildlifeSightings/rendering.test.mjs",
|
||||
"src/layers/wildlifeSightings/sprites.test.mjs",
|
||||
"src/layers/wildlifeSightings/performance.test.mjs",
|
||||
"src/layers/wildlifeSightings/model.perf.test.mjs",
|
||||
"src/ui/wildlifeSightingPlacement.js",
|
||||
"server/providers/inaturalist/queue.js",
|
||||
"server/providers/inaturalist/queue.test.mjs",
|
||||
"server/providers/inaturalist/grid.js",
|
||||
"server/providers/inaturalist/grid.test.mjs",
|
||||
"server/providers/inaturalist/grid.fixture.mjs",
|
||||
"src/layers/wildlifeSightings/performance.js",
|
||||
"src/layers/wildlifeSightings/renderPerformanceFixtures.mjs",
|
||||
"server/providers/inaturalist/speed.test.mjs",
|
||||
"src/layers/wildlifeSightings/render.perf.test.mjs",
|
||||
"src/layers/wildlifeSightings/cache.integration.test.mjs",
|
||||
"src/layers/wildlifeSightings/density.test.mjs",
|
||||
"src/layers/wildlifeSightings/selection.test.mjs",
|
||||
"server/providers/inaturalist/proxy.fixture.mjs"
|
||||
]
|
||||
@@ -154,7 +154,8 @@
|
||||
"src/ui/views/panels/GlobeCompass.vue",
|
||||
"src/ui/stores/recording.js",
|
||||
"src/ui/styleParameters.js",
|
||||
"src/ui/i18n.js"
|
||||
"src/ui/i18n.js",
|
||||
"src/ui/surfaceKeyboard.js"
|
||||
],
|
||||
"external": ["vue", "pinia", "vue-i18n"]
|
||||
},
|
||||
@@ -203,7 +204,22 @@
|
||||
"src/data/aisStreamAdapter.js",
|
||||
"src/data/aisWatchdog.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js"
|
||||
"src/i18n/marker.js",
|
||||
"server/providers/acartia.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"server/providers/common/userAgent.js",
|
||||
"server/providers/inaturalist/client.js",
|
||||
"server/providers/inaturalist/observations.js",
|
||||
"src/layers/wildlifeSightings/records.js",
|
||||
"src/layers/wildlifeSightings/policy.js",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/sources/rateLimit.js",
|
||||
"src/layers/wildlifeSightings/bounds.js",
|
||||
"src/layers/wildlifeSightings/acartiaTaxa.js",
|
||||
"server/providers/inaturalist/grid.js",
|
||||
"src/layers/wildlifeSightings/gridViewport.js",
|
||||
"server/providers/inaturalist/queue.js"
|
||||
],
|
||||
"external": ["ws"]
|
||||
},
|
||||
@@ -1155,7 +1171,14 @@
|
||||
"src/ui/stores/globeActions.js",
|
||||
"src/ui/stores/recording.js",
|
||||
"src/ui/i18n.js",
|
||||
"src/ui/kit/UiSegmented.vue"
|
||||
"src/ui/kit/UiSegmented.vue",
|
||||
"src/ui/wildlifeSightingCard.js",
|
||||
"src/layers/wildlifeSightings/records.js",
|
||||
"src/layers/wildlifeSightings/acartiaTaxa.js",
|
||||
"src/ui/views/panels/WildlifeSightingCard.vue",
|
||||
"src/ui/wildlifeSightingPlacement.js",
|
||||
"src/overlays/worldOverlayChrome.js",
|
||||
"src/layers/wildlifeSightings/photoText.js"
|
||||
],
|
||||
"external": ["cesium", "vue", "pinia", "vue-i18n"]
|
||||
},
|
||||
@@ -3270,7 +3293,32 @@
|
||||
"src/voice/osmActions.js",
|
||||
"src/layers/osmPlaces/index.js",
|
||||
"src/app/layers/osmPlaces.js",
|
||||
"src/voice/areaActions.js"
|
||||
"src/voice/areaActions.js",
|
||||
"src/app/layers/wildlifeSightings.js",
|
||||
"src/layers/wildlifeSightings/cards.js",
|
||||
"src/layers/wildlifeSightings/controls.js",
|
||||
"src/layers/wildlifeSightings/index.js",
|
||||
"src/layers/wildlifeSightings/lifecycle.js",
|
||||
"src/layers/wildlifeSightings/model.js",
|
||||
"src/layers/wildlifeSightings/photos.js",
|
||||
"src/layers/wildlifeSightings/policy.js",
|
||||
"src/layers/wildlifeSightings/records.js",
|
||||
"src/layers/wildlifeSightings/rendering.js",
|
||||
"src/layers/wildlifeSightings/density.js",
|
||||
"src/layers/wildlifeSightings/selection.js",
|
||||
"src/layers/wildlifeSightings/source.js",
|
||||
"src/layers/wildlifeSightings/viewport.js",
|
||||
"src/layers/wildlifeSightings/bounds.js",
|
||||
"src/layers/wildlifeSightings/acartiaTaxa.js",
|
||||
"src/ui/wildlifeSightingCard.js",
|
||||
"src/ui/views/panels/WildlifeSightingCard.vue",
|
||||
"src/layers/wildlifeSightings/gridViewport.js",
|
||||
"src/layers/wildlifeSightings/billboardModel.js",
|
||||
"src/layers/wildlifeSightings/sprites.js",
|
||||
"src/ui/wildlifeSightingPlacement.js",
|
||||
"src/overlays/worldOverlayChrome.js",
|
||||
"src/layers/wildlifeSightings/photoText.js",
|
||||
"src/layers/wildlifeSightings/performance.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3896,7 +3944,30 @@
|
||||
"src/data/local_data/wof_neighborhoods/files.js",
|
||||
"src/layers/osmPlaces/model.js",
|
||||
"src/layers/osmPlaces/index.js",
|
||||
"src/app/layers/osmPlaces.js"
|
||||
"src/app/layers/osmPlaces.js",
|
||||
"src/app/layers/wildlifeSightings.js",
|
||||
"src/layers/wildlifeSightings/cards.js",
|
||||
"src/layers/wildlifeSightings/controls.js",
|
||||
"src/layers/wildlifeSightings/index.js",
|
||||
"src/layers/wildlifeSightings/lifecycle.js",
|
||||
"src/layers/wildlifeSightings/model.js",
|
||||
"src/layers/wildlifeSightings/photos.js",
|
||||
"src/layers/wildlifeSightings/policy.js",
|
||||
"src/layers/wildlifeSightings/records.js",
|
||||
"src/layers/wildlifeSightings/rendering.js",
|
||||
"src/layers/wildlifeSightings/density.js",
|
||||
"src/layers/wildlifeSightings/selection.js",
|
||||
"src/layers/wildlifeSightings/source.js",
|
||||
"src/layers/wildlifeSightings/viewport.js",
|
||||
"src/ui/rail/utcClock.js",
|
||||
"src/layers/wildlifeSightings/bounds.js",
|
||||
"src/layers/wildlifeSightings/acartiaTaxa.js",
|
||||
"src/layers/wildlifeSightings/gridViewport.js",
|
||||
"src/layers/wildlifeSightings/billboardModel.js",
|
||||
"src/layers/wildlifeSightings/sprites.js",
|
||||
"src/overlays/worldOverlayChrome.js",
|
||||
"src/layers/wildlifeSightings/photoText.js",
|
||||
"src/layers/wildlifeSightings/performance.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -4432,7 +4503,15 @@
|
||||
"src/layers/airQuality/model.js",
|
||||
"src/layers/airQuality/source.js",
|
||||
"src/layers/droneAirspace/source.js",
|
||||
"src/layers/droneAirspace/records.js"
|
||||
"src/layers/droneAirspace/records.js",
|
||||
"src/layers/wildlifeSightings/policy.js",
|
||||
"src/layers/wildlifeSightings/records.js",
|
||||
"src/layers/wildlifeSightings/source.js",
|
||||
"src/layers/wildlifeSightings/viewport.js",
|
||||
"src/layers/wildlifeSightings/bounds.js",
|
||||
"src/layers/wildlifeSightings/acartiaTaxa.js",
|
||||
"src/layers/wildlifeSightings/gridViewport.js",
|
||||
"src/layers/wildlifeSightings/performance.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -4583,7 +4662,8 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/overlays/worldOverlayChrome.js"
|
||||
],
|
||||
"external": ["cesium", "egm96-universal", "pbf"]
|
||||
},
|
||||
@@ -5065,5 +5145,68 @@
|
||||
"src/i18n/marker.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"wildlife-sightings": {
|
||||
"exports": ["./layers/wildlife-sightings"],
|
||||
"modules": [
|
||||
"src/data/dataCredits.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/ar.json",
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/i18n/marker.js",
|
||||
"src/layers/wildlifeSightings/cards.js",
|
||||
"src/layers/wildlifeSightings/controls.js",
|
||||
"src/layers/wildlifeSightings/index.js",
|
||||
"src/layers/wildlifeSightings/lifecycle.js",
|
||||
"src/layers/wildlifeSightings/model.js",
|
||||
"src/layers/wildlifeSightings/photos.js",
|
||||
"src/layers/wildlifeSightings/policy.js",
|
||||
"src/layers/wildlifeSightings/records.js",
|
||||
"src/layers/wildlifeSightings/rendering.js",
|
||||
"src/layers/wildlifeSightings/density.js",
|
||||
"src/layers/wildlifeSightings/selection.js",
|
||||
"src/layers/wildlifeSightings/source.js",
|
||||
"src/layers/wildlifeSightings/viewport.js",
|
||||
"src/overlays/worldOverlayDraw.js",
|
||||
"src/overlays/worldOverlayTokens.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/layers/wildlifeSightings/bounds.js",
|
||||
"src/layers/wildlifeSightings/acartiaTaxa.js",
|
||||
"src/ui/statusText.js",
|
||||
"src/ui/layerNames.js",
|
||||
"src/layers/wildlifeSightings/gridViewport.js",
|
||||
"src/layers/wildlifeSightings/billboardModel.js",
|
||||
"src/layers/wildlifeSightings/sprites.js",
|
||||
"src/layers/wildlifeSightings/photoText.js",
|
||||
"src/layers/wildlifeSightings/performance.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"wildlife-sightings-source": {
|
||||
"exports": ["./layers/wildlife-sightings/source"],
|
||||
"modules": [
|
||||
"src/i18n/marker.js",
|
||||
"src/layers/wildlifeSightings/policy.js",
|
||||
"src/layers/wildlifeSightings/records.js",
|
||||
"src/layers/wildlifeSightings/source.js",
|
||||
"src/layers/wildlifeSightings/viewport.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/layers/wildlifeSightings/bounds.js",
|
||||
"src/layers/wildlifeSightings/acartiaTaxa.js",
|
||||
"src/layers/wildlifeSightings/gridViewport.js",
|
||||
"src/layers/wildlifeSightings/performance.js"
|
||||
],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
discoverUnitTestFiles,
|
||||
UNIT_TEST_NODE_FLAGS,
|
||||
} from './run-unit-tests.mjs';
|
||||
|
||||
/** Discover every layer/provider benchmark, including those added by other branches. */
|
||||
export function discoverPerfTestFiles(root = process.cwd()) {
|
||||
return discoverUnitTestFiles(root).filter((file) =>
|
||||
file.endsWith('.perf.test.mjs'),
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared by benchmarks so normal unit runs visibly skip timing assertions. */
|
||||
export function perfBudgetTestOptions(env = process.env) {
|
||||
return {
|
||||
skip:
|
||||
env.GEV_PERF_BUDGETS === '1'
|
||||
? false
|
||||
: 'Timing budgets are opt-in; run npm run test:perf (GEV_PERF_BUDGETS=1). Deterministic delta/write checks still run in the normal suite.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a serial invocation so benchmarks do not compete for CPU time. */
|
||||
export function perfTestArgs(files) {
|
||||
// A single serial test process keeps benchmark files from competing with
|
||||
// each other; the normal suite still discovers them and reports their skips.
|
||||
return [
|
||||
...UNIT_TEST_NODE_FLAGS,
|
||||
'--test',
|
||||
'--test-concurrency=1',
|
||||
'--test-isolation=none',
|
||||
...files,
|
||||
];
|
||||
}
|
||||
|
||||
/** Run discovered benchmarks with wall-clock budgets enabled. */
|
||||
export function runPerfTests({
|
||||
root = process.cwd(),
|
||||
env = process.env,
|
||||
run = spawnSync,
|
||||
} = {}) {
|
||||
const files = discoverPerfTestFiles(root);
|
||||
if (!files.length) throw new Error('No *.perf.test.mjs benchmarks found');
|
||||
const result = run(process.execPath, perfTestArgs(files), {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: { ...env, GEV_PERF_BUDGETS: '1' },
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
return result.status ?? 1;
|
||||
}
|
||||
|
||||
const invoked = process.argv[1]
|
||||
? pathToFileURL(path.resolve(process.argv[1])).href
|
||||
: '';
|
||||
if (import.meta.url === invoked) process.exitCode = runPerfTests();
|
||||
@@ -0,0 +1,181 @@
|
||||
import { normalizeAcartiaSightings } from '../../src/layers/wildlifeSightings/records.js';
|
||||
import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js';
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||
import { providerUserAgent } from './common/userAgent.js';
|
||||
|
||||
const API_URL = 'https://acartia.io/api/v1/sightings/current';
|
||||
// All views share the provider's seven-day snapshot.
|
||||
const TTL_MS = 600_000;
|
||||
const STALE_MAX_MS = 24 * 60 * 60_000;
|
||||
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||
const TIMEOUT_MS = 15_000;
|
||||
const COOLDOWN_MS = 60_000;
|
||||
const COOLDOWN_MIN_MS = 5000;
|
||||
const COOLDOWN_MAX_MS = 60 * 60_000;
|
||||
const FAILURE_COOLDOWN_MS = 15_000;
|
||||
|
||||
/** Bound upstream Retry-After seconds or dates to five seconds–one hour. */
|
||||
export function acartiaRetryCooldownMs(raw, nowMs) {
|
||||
const value = typeof raw === 'string' ? raw.trim() : '';
|
||||
if (value) {
|
||||
const seconds = /^\d+(?:\.\d+)?$/.test(value) ? Number(value) : NaN;
|
||||
const deadline =
|
||||
!Number.isFinite(seconds) && /[a-z]/i.test(value)
|
||||
? Date.parse(value)
|
||||
: NaN;
|
||||
const delay = Number.isFinite(seconds) ? seconds * 1000 : deadline - nowMs;
|
||||
if (Number.isFinite(delay) && delay >= 0)
|
||||
return Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, delay));
|
||||
}
|
||||
return COOLDOWN_MS;
|
||||
}
|
||||
|
||||
/** Salish Sea coverage. Precise coordinates and comments never enter cache. */
|
||||
export function acartiaProxy({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
now = () => Date.now(),
|
||||
cache = new Map(),
|
||||
warn = (...args) => console.warn(...args),
|
||||
} = {}) {
|
||||
const inFlight = new Map();
|
||||
const allow = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: 60,
|
||||
globalMax: 600,
|
||||
now,
|
||||
});
|
||||
let cooldownUntil = 0;
|
||||
let cooldownStatus = 429;
|
||||
let warnedClamp = false;
|
||||
|
||||
const retrySeconds = () =>
|
||||
Math.max(1, Math.ceil((cooldownUntil - now()) / 1000));
|
||||
const unavailable = (status = 502) =>
|
||||
Object.assign(new Error('acartia_unavailable'), {
|
||||
status,
|
||||
retryAfterS: retrySeconds(),
|
||||
});
|
||||
|
||||
function coolDown(status, delay) {
|
||||
const until = now() + delay;
|
||||
if (until > cooldownUntil) {
|
||||
cooldownUntil = until;
|
||||
cooldownStatus = status;
|
||||
} else if (status === 429 && now() < cooldownUntil) cooldownStatus = 429;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (now() < cooldownUntil) throw unavailable(cooldownStatus);
|
||||
const signal = AbortSignal.timeout(TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetchImpl(API_URL, {
|
||||
signal,
|
||||
redirect: 'error',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': providerUserAgent('wildlife-sightings'),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
if (response.status === 429) {
|
||||
const delay = acartiaRetryCooldownMs(
|
||||
response.headers.get('retry-after'),
|
||||
now(),
|
||||
);
|
||||
if (delay === COOLDOWN_MAX_MS && !warnedClamp) {
|
||||
warnedClamp = true;
|
||||
warn('[acartia-proxy] upstream cooldown capped at one hour');
|
||||
}
|
||||
coolDown(429, delay);
|
||||
throw unavailable(429);
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
// This endpoint documents a JSON array, not a results/data envelope.
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
MAX_RESPONSE_BYTES,
|
||||
signal,
|
||||
);
|
||||
const rows = normalizeAcartiaSightings(payload, now());
|
||||
// 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);
|
||||
warn('[acartia-proxy] upstream refresh failed');
|
||||
throw unavailable(error.status === 429 ? 429 : 502);
|
||||
}
|
||||
}
|
||||
|
||||
function age(entry) {
|
||||
if (!entry) return Infinity;
|
||||
const elapsed = now() - entry.savedAt;
|
||||
return elapsed < 0 ? Infinity : elapsed;
|
||||
}
|
||||
|
||||
async function acquire() {
|
||||
const previous = cache.get('sightings');
|
||||
if (age(previous) < TTL_MS) return { value: previous.value, stale: false };
|
||||
if (age(previous) > STALE_MAX_MS) cache.delete('sightings');
|
||||
try {
|
||||
const { promise } = coalesceProxyRequest(
|
||||
inFlight,
|
||||
'sightings',
|
||||
async () => {
|
||||
const value = await load();
|
||||
cache.set('sightings', { value, savedAt: now() });
|
||||
return value;
|
||||
},
|
||||
);
|
||||
return { value: await promise, stale: false };
|
||||
} catch (error) {
|
||||
if (age(previous) <= STALE_MAX_MS)
|
||||
return { value: previous.value, stale: true };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handler(req, res) {
|
||||
const json = (status, value, { stale = false, retryAfterS } = {}) => {
|
||||
if (res.destroyed) return;
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
...(status === 405 ? { Allow: 'GET' } : {}),
|
||||
...(retryAfterS ? { 'Retry-After': String(retryAfterS) } : {}),
|
||||
...(stale ? { 'X-Data-Stale': 'true' } : {}),
|
||||
});
|
||||
res.end(JSON.stringify(value));
|
||||
};
|
||||
if (admitSameSiteKeyless(req, res)) return;
|
||||
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
|
||||
const pathname = (req.url || '/').split('?')[0];
|
||||
if (pathname !== '/sightings') return json(404, { error: 'unknown_route' });
|
||||
if (!allow(clientKey(req)))
|
||||
return json(429, { error: 'rate_limited' }, { retryAfterS: 60 });
|
||||
try {
|
||||
const { value, stale } = await acquire();
|
||||
return json(200, stale ? { ...value, stale: true } : value, { stale });
|
||||
} catch (error) {
|
||||
return json(
|
||||
error.status === 429 ? 429 : 502,
|
||||
{ error: 'acartia_unavailable' },
|
||||
{ retryAfterS: error.retryAfterS },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'acartia',
|
||||
configureServer({ middlewares }) {
|
||||
middlewares.use('/api/acartia', handler);
|
||||
},
|
||||
configurePreviewServer({ middlewares }) {
|
||||
middlewares.use('/api/acartia', handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { acartiaProxy, acartiaRetryCooldownMs } from './acartia.js';
|
||||
|
||||
const NOW = Date.parse('2026-11-04T18:05:00Z');
|
||||
const sighting = (id = 'synthetic-1') => ({
|
||||
entry_id: id,
|
||||
latitude: 48.12345,
|
||||
longitude: -123.12345,
|
||||
created: '2026-11-04 17:00:00',
|
||||
type: 'Orca',
|
||||
no_sighted: '5',
|
||||
photo_url: 'https://provider.example/photos/synthetic.jpg',
|
||||
data_source_name: 'Synthetic Whale Network',
|
||||
data_source_entity: 'Community reports',
|
||||
trusted: true,
|
||||
data_source_comments: '[email protected] +1-555-000-0000',
|
||||
});
|
||||
|
||||
function install(options = {}, hook = 'configureServer') {
|
||||
let handler;
|
||||
const plugin = acartiaProxy({ now: () => NOW, warn() {}, ...options });
|
||||
assert.equal(plugin.name, 'acartia');
|
||||
assert.equal(typeof plugin.configureServer, 'function');
|
||||
assert.equal(typeof plugin.configurePreviewServer, 'function');
|
||||
plugin[hook]({
|
||||
middlewares: {
|
||||
use(mount, callback) {
|
||||
assert.equal(mount, '/api/acartia');
|
||||
handler = callback;
|
||||
},
|
||||
},
|
||||
});
|
||||
return async (
|
||||
url = '/sightings',
|
||||
{ method = 'GET', peer = 'local', headers = {} } = {},
|
||||
) => {
|
||||
const res = {
|
||||
headers: {},
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
setHeader(key, value) {
|
||||
this.headers[key] = value;
|
||||
},
|
||||
end(value) {
|
||||
this.status ??= this.statusCode;
|
||||
this.body = JSON.parse(value);
|
||||
},
|
||||
};
|
||||
await handler(
|
||||
{ url, method, headers, socket: { remoteAddress: peer } },
|
||||
res,
|
||||
);
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
test('cache stores only supported marine mammals inside the coverage box', async () => {
|
||||
const cache = new Map();
|
||||
const request = install({
|
||||
cache,
|
||||
fetchImpl: async () =>
|
||||
Response.json([
|
||||
sighting('orca'),
|
||||
{ ...sighting('seal'), type: 'Harbor seal' },
|
||||
{ ...sighting('unknown'), type: 'Unknown' },
|
||||
{ ...sighting('fish'), type: 'Whale shark' },
|
||||
{ ...sighting('outside'), latitude: 37.8 },
|
||||
{ ...sighting('rounded-edge'), latitude: 45.49999 },
|
||||
]),
|
||||
});
|
||||
const result = await request();
|
||||
assert.equal(result.status, 200);
|
||||
for (const rows of [result.body.rows, cache.get('sightings').value.rows]) {
|
||||
assert.deepEqual(
|
||||
rows.map((row) => [row.stableId, row.cetacean]),
|
||||
[
|
||||
['acartia:orca', true],
|
||||
['acartia:seal', false],
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
test(`${hook}: documented array is normalized and coarsened before caching`, async () => {
|
||||
const cache = new Map();
|
||||
let calls = 0;
|
||||
const request = install(
|
||||
{
|
||||
cache,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls += 1;
|
||||
assert.equal(url, 'https://acartia.io/api/v1/sightings/current');
|
||||
assert.equal(options.redirect, 'error');
|
||||
assert.ok(options.signal instanceof AbortSignal);
|
||||
assert.equal(options.headers.Accept, 'application/json');
|
||||
assert.equal(
|
||||
options.headers['User-Agent'],
|
||||
'gods-eye-view-wildlife-sightings-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
);
|
||||
return Response.json([sighting()]);
|
||||
},
|
||||
},
|
||||
hook,
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(cache.size, 0);
|
||||
const result = await request(
|
||||
'/sightings?url=https://attacker.example&latitude=1',
|
||||
);
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(result.headers['Cache-Control'], 'no-store');
|
||||
assert.equal(result.body.fetchedAt, NOW);
|
||||
assert.equal(result.body.rows[0].stableId, 'acartia:synthetic-1');
|
||||
assert.equal(result.body.rows[0].lat, 48.12);
|
||||
assert.equal(result.body.rows[0].lon, -123.12);
|
||||
assert.equal(result.body.rows[0].provider.name, 'Synthetic Whale Network');
|
||||
const retained = cache.get('sightings');
|
||||
assert.equal(retained.value.rows[0].lat, 48.12);
|
||||
assert.equal(retained.value.rows[0].lon, -123.12);
|
||||
for (const value of [result.body, [...cache.values()]])
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(value),
|
||||
/private-observer|555|data_source_comments|48\.12345|123\.12345/,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test('gate, method and fixed route run before cache or upstream', async () => {
|
||||
const cache = new Map();
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
cache,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return Response.json([sighting()]);
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < 65; i += 1) {
|
||||
assert.equal(
|
||||
(
|
||||
await request('/sightings', {
|
||||
headers: {
|
||||
host: 'localhost:5173',
|
||||
origin: 'https://foreign.example',
|
||||
'sec-fetch-site': 'cross-site',
|
||||
},
|
||||
})
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
}
|
||||
const method = await request('/sightings', { method: 'POST' });
|
||||
assert.equal(method.status, 405);
|
||||
assert.equal(method.headers.Allow, 'GET');
|
||||
for (const route of ['/', '/unknown', '/sightings/extra']) {
|
||||
const result = await request(route);
|
||||
assert.equal(result.status, 404);
|
||||
assert.equal(result.body.error, 'unknown_route');
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(cache.size, 0);
|
||||
const sameOrigin = await request('/sightings', {
|
||||
headers: {
|
||||
host: 'gev.example',
|
||||
origin: 'https://gev.example',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-forwarded-proto': 'https',
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
},
|
||||
});
|
||||
assert.equal(sameOrigin.status, 200);
|
||||
});
|
||||
|
||||
test('request 61 is locally limited even with a fresh cache', async () => {
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return Response.json([sighting()]);
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < 60; i += 1) assert.equal((await request()).status, 200);
|
||||
const limited = await request();
|
||||
assert.equal(limited.status, 429);
|
||||
assert.equal(limited.body.error, 'rate_limited');
|
||||
assert.equal(limited.headers['Retry-After'], '60');
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('one global snapshot single-flights and stays fresh for ten minutes', async () => {
|
||||
let at = NOW;
|
||||
let calls = 0;
|
||||
let release;
|
||||
const request = install({
|
||||
now: () => at,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1)
|
||||
await new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return Response.json([sighting()]);
|
||||
},
|
||||
});
|
||||
const first = request();
|
||||
const concurrent = request();
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(calls, 1);
|
||||
release();
|
||||
assert.deepEqual((await first).body, (await concurrent).body);
|
||||
at += 599_999;
|
||||
await request();
|
||||
assert.equal(calls, 1);
|
||||
at += 1;
|
||||
await request();
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
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: '' }],
|
||||
[{ ...sighting(), latitude: 37.8 }],
|
||||
[{ ...sighting(), type: 'Unknown' }],
|
||||
]) {
|
||||
let at = NOW;
|
||||
let calls = 0;
|
||||
let empty = false;
|
||||
const cache = new Map();
|
||||
const request = install({
|
||||
cache,
|
||||
now: () => at,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return Response.json(empty ? quiet : [sighting()]);
|
||||
},
|
||||
});
|
||||
assert.equal((await request()).body.rows.length, 1);
|
||||
empty = true;
|
||||
at += 600_001;
|
||||
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;
|
||||
const request = install({
|
||||
now: () => at,
|
||||
fetchImpl: async () => {
|
||||
if (fail) throw new Error('private upstream detail');
|
||||
return Response.json([sighting()]);
|
||||
},
|
||||
});
|
||||
const first = await request();
|
||||
fail = true;
|
||||
at += 23 * 60 * 60_000 + 59 * 60_000;
|
||||
const stale = await request();
|
||||
assert.equal(stale.status, 200);
|
||||
assert.equal(stale.body.stale, true);
|
||||
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
|
||||
at += 2 * 60_000;
|
||||
const expired = await request();
|
||||
assert.equal(expired.status, 502);
|
||||
assert.deepEqual(expired.body, { error: 'acartia_unavailable' });
|
||||
});
|
||||
|
||||
test('upstream 429 honors Retry-After, caps it, and spends no requests while cooling down', async () => {
|
||||
assert.equal(acartiaRetryCooldownMs('2', NOW), 5000);
|
||||
assert.equal(acartiaRetryCooldownMs('999999', NOW), 3_600_000);
|
||||
assert.equal(
|
||||
acartiaRetryCooldownMs(new Date(NOW + 120_000).toUTCString(), NOW),
|
||||
120_000,
|
||||
);
|
||||
assert.equal(acartiaRetryCooldownMs(null, NOW), 60_000);
|
||||
let at = NOW;
|
||||
let calls = 0;
|
||||
const warnings = [];
|
||||
const request = install({
|
||||
now: () => at,
|
||||
warn: (message) => warnings.push(message),
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return new Response('limit', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '999999' },
|
||||
});
|
||||
},
|
||||
});
|
||||
const first = await request();
|
||||
assert.equal(first.status, 429);
|
||||
assert.equal(first.body.error, 'acartia_unavailable');
|
||||
assert.equal(first.headers['Retry-After'], '3600');
|
||||
at += 5000;
|
||||
const cooling = await request();
|
||||
assert.equal(cooling.headers['Retry-After'], '3595');
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(
|
||||
warnings.filter((message) => message.includes('capped')).length,
|
||||
1,
|
||||
);
|
||||
at += 3_600_000;
|
||||
await request();
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(
|
||||
warnings.filter((message) => message.includes('capped')).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('upstream 429 also serves a prior snapshot stale', async () => {
|
||||
let at = NOW;
|
||||
let fail = false;
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
now: () => at,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return fail
|
||||
? new Response('limit', { status: 429 })
|
||||
: Response.json([sighting()]);
|
||||
},
|
||||
});
|
||||
const first = await request();
|
||||
fail = true;
|
||||
at += 600_001;
|
||||
const stale = await request();
|
||||
assert.equal(stale.status, 200);
|
||||
assert.equal(stale.body.stale, true);
|
||||
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
|
||||
at += 1000;
|
||||
assert.equal((await request()).body.stale, true);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('bad JSON, 5xx and capped bodies start a 15 second failure cooldown', async () => {
|
||||
for (const bad of [
|
||||
() => new Response('{malformed'),
|
||||
() => new Response('private upstream message', { status: 503 }),
|
||||
() =>
|
||||
new Response('{}', {
|
||||
headers: { 'Content-Length': String(2 * 1024 * 1024 + 1) },
|
||||
}),
|
||||
]) {
|
||||
let at = NOW;
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
now: () => at,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return calls === 1 ? bad() : Response.json([sighting()]);
|
||||
},
|
||||
});
|
||||
const first = await request();
|
||||
assert.equal(first.status, 502);
|
||||
assert.deepEqual(first.body, { error: 'acartia_unavailable' });
|
||||
assert.equal(first.headers['Retry-After'], '15');
|
||||
at += 14_999;
|
||||
assert.equal((await request()).headers['Retry-After'], '1');
|
||||
assert.equal(calls, 1);
|
||||
at += 1;
|
||||
assert.equal((await request()).status, 200);
|
||||
assert.equal(calls, 2);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Identify each provider while keeping the project contact URL consistent. */
|
||||
export function providerUserAgent(name) {
|
||||
return `gods-eye-view-${name}-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)`;
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import path from 'node:path';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { normalizeInatObservations } from '../../../src/layers/wildlifeSightings/records.js';
|
||||
import { readResponseJsonCapped } from '../common/http.js';
|
||||
import { clampInt } from '../common/query.js';
|
||||
import { providerUserAgent } from '../common/userAgent.js';
|
||||
import { projectInatGrid, validInatGridTile } from './grid.js';
|
||||
import { createInatQueue } from './queue.js';
|
||||
|
||||
const DAILY_BUDGET = 8000;
|
||||
const DAY_MS = 24 * 60 * 60_000;
|
||||
// One page is capped independently of upstream Content-Length.
|
||||
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||
const TIMEOUT_MS = 20_000;
|
||||
const API_URL = 'https://api.inaturalist.org/v1';
|
||||
// Request only the fields used by the public observation projection.
|
||||
const OBSERVATIONS_URL = 'https://api.inaturalist.org/v2/observations';
|
||||
const OBSERVATION_FIELDS =
|
||||
'(id:!t,uri:!t,location:!t,geojson:all,geoprivacy:!t,taxon_geoprivacy:!t,' +
|
||||
'obscured:!t,quality_grade:!t,observed_on:!t,' +
|
||||
'time_observed_at:!t,faves_count:!t,' +
|
||||
'num_identification_agreements:!t,' +
|
||||
'photos:(url:!t,attribution:!t,license_code:!t),' +
|
||||
'taxon:(name:!t,preferred_common_name:!t,iconic_taxon_name:!t,' +
|
||||
'threatened:!t))';
|
||||
const COOLDOWN_MS = 60_000;
|
||||
const FAILURE_COOLDOWN_MS = 15_000;
|
||||
|
||||
/** Seconds and HTTP dates are accepted; a missing value earns one minute. */
|
||||
export function inatRetryCooldownMs(raw, nowMs) {
|
||||
const value = typeof raw === 'string' ? raw.trim() : '';
|
||||
if (value) {
|
||||
const seconds = /^\d+(?:\.\d+)?$/.test(value) ? Number(value) : NaN;
|
||||
const deadline =
|
||||
!Number.isFinite(seconds) && /[a-z]/i.test(value)
|
||||
? Date.parse(value)
|
||||
: NaN;
|
||||
const delay = Number.isFinite(seconds) ? seconds * 1000 : deadline - nowMs;
|
||||
if (Number.isFinite(delay) && delay >= 0) return delay;
|
||||
}
|
||||
return COOLDOWN_MS;
|
||||
}
|
||||
|
||||
function upstreamError(code, status, retryAfterS, upstreamStatus) {
|
||||
return Object.assign(new Error(code), {
|
||||
code,
|
||||
status,
|
||||
retryAfterS,
|
||||
upstreamStatus,
|
||||
});
|
||||
}
|
||||
|
||||
function observationFilters(params) {
|
||||
const query = new URLSearchParams({
|
||||
verifiable: 'true',
|
||||
captive: 'false',
|
||||
// d1 is an observation DATE, unlike creation-time created_d1.
|
||||
d1: params.observedD1,
|
||||
});
|
||||
if (params.taxa !== 'whales') query.set('photos', 'true');
|
||||
if (params.quality === 'research') query.set('quality_grade', 'research');
|
||||
if (params.taxa === 'whales') query.set('taxon_id', '152871');
|
||||
else if (['Aves', 'Mammalia', 'Insecta', 'Plantae'].includes(params.taxa))
|
||||
query.set('iconic_taxa', params.taxa);
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared iNaturalist governor. Only request counters are persisted; observation
|
||||
* payloads are projected immediately and are never written to disk.
|
||||
*/
|
||||
export function createInatClient({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
now = () => Date.now(),
|
||||
cacheDir = path.join(process.cwd(), '.gev-cache', 'inaturalist'),
|
||||
warn = (...args) => console.warn(...args),
|
||||
queueOptions,
|
||||
} = {}) {
|
||||
const enqueue = createInatQueue(queueOptions);
|
||||
let budget = null;
|
||||
let budgetLoaded = false;
|
||||
let budgetTail = Promise.resolve();
|
||||
let cooldownUntil = 0;
|
||||
// An unavailable UTFGrid tile must never place observation queries into a
|
||||
// 502 cooldown. Only an upstream 429 is a provider-wide restriction.
|
||||
const failures = new Map();
|
||||
|
||||
function dailyLimit() {
|
||||
return clampInt(
|
||||
process.env.INATURALIST_DAILY_BUDGET,
|
||||
1,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
DAILY_BUDGET,
|
||||
);
|
||||
}
|
||||
|
||||
function day() {
|
||||
return new Date(now()).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function currentBudget() {
|
||||
const date = day();
|
||||
if (budget?.date !== date) budget = { date, count: 0 };
|
||||
return budget;
|
||||
}
|
||||
|
||||
async function readBudgetOnce() {
|
||||
if (budgetLoaded) return;
|
||||
budgetLoaded = true;
|
||||
if (cacheDir === null) return;
|
||||
try {
|
||||
const value = JSON.parse(
|
||||
await fs.readFile(path.join(cacheDir, 'budget.json'), 'utf8'),
|
||||
);
|
||||
if (
|
||||
typeof value?.date === 'string' &&
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(value.date) &&
|
||||
Number.isSafeInteger(value.count) &&
|
||||
value.count >= 0
|
||||
)
|
||||
budget = { date: value.date, count: value.count };
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT')
|
||||
warn('[inaturalist-proxy] budget read failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function persistBudget(value) {
|
||||
if (cacheDir === null) return;
|
||||
const destination = path.join(cacheDir, 'budget.json');
|
||||
const temporary = `${destination}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.mkdir(cacheDir, { recursive: true });
|
||||
await fs.writeFile(temporary, JSON.stringify(value), 'utf8');
|
||||
await fs.rename(temporary, destination);
|
||||
} catch {
|
||||
await fs.rm(temporary, { force: true }).catch(() => {});
|
||||
warn('[inaturalist-proxy] budget write failed');
|
||||
}
|
||||
}
|
||||
|
||||
function retrySeconds(until = cooldownUntil) {
|
||||
return Math.max(0, Math.ceil((until - now()) / 1000));
|
||||
}
|
||||
|
||||
function checkCooldown(resource) {
|
||||
if (now() < cooldownUntil)
|
||||
throw upstreamError('inaturalist_rate_limited', 429, retrySeconds(), 429);
|
||||
const failure = failures.get(resource);
|
||||
if (failure && now() < failure.until)
|
||||
throw upstreamError(
|
||||
'inaturalist_unavailable',
|
||||
502,
|
||||
retrySeconds(failure.until),
|
||||
failure.status,
|
||||
);
|
||||
failures.delete(resource);
|
||||
}
|
||||
|
||||
// Reserve and atomically persist one attempt before issuing it. A recovered
|
||||
// tail prevents one disk/reservation failure from poisoning future calls.
|
||||
function reserveAttempt(signal, resource) {
|
||||
const result = budgetTail.then(async () => {
|
||||
signal?.throwIfAborted();
|
||||
checkCooldown(resource);
|
||||
await readBudgetOnce();
|
||||
signal?.throwIfAborted();
|
||||
checkCooldown(resource);
|
||||
const value = currentBudget();
|
||||
if (value.count >= dailyLimit()) {
|
||||
const retry = Math.max(
|
||||
1,
|
||||
Math.ceil((Date.parse(`${day()}T00:00:00Z`) + DAY_MS - now()) / 1000),
|
||||
);
|
||||
throw upstreamError('inaturalist_budget_exhausted', 503, retry);
|
||||
}
|
||||
value.count += 1;
|
||||
await persistBudget({ ...value });
|
||||
});
|
||||
budgetTail = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function requestProjected(url, project, resource, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
checkCooldown(resource);
|
||||
return enqueue(() => dispatch(url, project, resource, signal), {
|
||||
signal,
|
||||
priority: resource === 'observations' ? 0 : 1,
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatch(url, project, resource, signal) {
|
||||
signal?.throwIfAborted();
|
||||
await reserveAttempt(signal, resource);
|
||||
signal?.throwIfAborted();
|
||||
checkCooldown(resource);
|
||||
const timeout = AbortSignal.timeout(TIMEOUT_MS);
|
||||
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
let upstreamStatus;
|
||||
let retryDelay = FAILURE_COOLDOWN_MS;
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
signal: requestSignal,
|
||||
redirect: 'error',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': providerUserAgent('wildlife-sightings'),
|
||||
},
|
||||
});
|
||||
upstreamStatus = response.status;
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
if (response.status === 429) {
|
||||
const delay = inatRetryCooldownMs(
|
||||
response.headers.get('retry-after'),
|
||||
now(),
|
||||
);
|
||||
cooldownUntil = Math.max(cooldownUntil, now() + delay);
|
||||
throw upstreamError(
|
||||
'inaturalist_rate_limited',
|
||||
429,
|
||||
retrySeconds(),
|
||||
429,
|
||||
);
|
||||
}
|
||||
if (response.headers.has('retry-after'))
|
||||
retryDelay = inatRetryCooldownMs(
|
||||
response.headers.get('retry-after'),
|
||||
now(),
|
||||
);
|
||||
throw upstreamError('inaturalist_unavailable', 502);
|
||||
}
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
MAX_RESPONSE_BYTES,
|
||||
requestSignal,
|
||||
);
|
||||
return project(payload);
|
||||
} catch (error) {
|
||||
// Log only safe diagnostic categories, never a URL, body, status text or
|
||||
// exception message (each can contain request/private information).
|
||||
const reason = signal?.aborted
|
||||
? 'abort'
|
||||
: timeout.aborted || error.name === 'TimeoutError'
|
||||
? 'timeout'
|
||||
: upstreamStatus && upstreamStatus >= 400
|
||||
? 'http'
|
||||
: error.code === 'RESPONSE_TOO_LARGE'
|
||||
? 'response-too-large'
|
||||
: upstreamStatus
|
||||
? 'invalid-payload'
|
||||
: 'network';
|
||||
warn(
|
||||
`[inaturalist-proxy] upstream refresh failed resource=${resource} status=${upstreamStatus ?? 'none'} reason=${reason}`,
|
||||
);
|
||||
if (signal?.aborted) throw error;
|
||||
// Network, abort, schema and size failures are not upstream cooldown
|
||||
// signals. A later healthy request may recover immediately.
|
||||
if (upstreamStatus >= 500)
|
||||
failures.set(resource, {
|
||||
until: now() + retryDelay,
|
||||
status: upstreamStatus,
|
||||
});
|
||||
const failure = failures.get(resource);
|
||||
throw upstreamError(
|
||||
error.status === 429
|
||||
? 'inaturalist_rate_limited'
|
||||
: 'inaturalist_unavailable',
|
||||
error.status === 429 ? 429 : 502,
|
||||
error.status === 429
|
||||
? retrySeconds()
|
||||
: failure
|
||||
? retrySeconds(failure.until)
|
||||
: undefined,
|
||||
upstreamStatus === 429 || upstreamStatus >= 500
|
||||
? upstreamStatus
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Request one bounded observation sample and project its public fields. */
|
||||
async function getObservations(params, options = {}) {
|
||||
const query = observationFilters(params);
|
||||
// View samples stay stable in the route cache until their TTL expires.
|
||||
query.set('order_by', params.scope === 'view' ? 'random' : 'observed_on');
|
||||
if (params.scope !== 'view') query.set('order', 'desc');
|
||||
query.set('per_page', '200');
|
||||
query.set('fields', OBSERVATION_FIELDS);
|
||||
if (params.scope === 'view') {
|
||||
query.set('swlat', String(params.box.south));
|
||||
query.set('swlng', String(params.box.west));
|
||||
query.set('nelat', String(params.box.north));
|
||||
query.set('nelng', String(params.box.east));
|
||||
}
|
||||
return requestProjected(
|
||||
`${OBSERVATIONS_URL}?${query}`,
|
||||
(payload) => {
|
||||
if (
|
||||
!Array.isArray(payload?.results) ||
|
||||
!Number.isSafeInteger(payload.total_results) ||
|
||||
payload.total_results < 0
|
||||
)
|
||||
throw upstreamError('inaturalist_unavailable', 502);
|
||||
const rows = normalizeInatObservations(payload.results, {
|
||||
nowMs: now(),
|
||||
windowMs: (params.taxa === 'whales' ? 7 : 2) * DAY_MS,
|
||||
});
|
||||
// Empty views need total_results: 0. A wholly invalid page must not
|
||||
// replace good data. Valid rows excluded by the finer rolling-time
|
||||
// cutoff are a healthy empty result of the upstream DATE over-fetch.
|
||||
if (
|
||||
!rows ||
|
||||
(!rows.length &&
|
||||
(payload.results.length || payload.total_results) &&
|
||||
!normalizeInatObservations(payload.results)?.length)
|
||||
)
|
||||
throw upstreamError('inaturalist_unavailable', 502);
|
||||
return {
|
||||
rows,
|
||||
totalResults: payload.total_results,
|
||||
...(payload.total_results > payload.results.length
|
||||
? { partial: true, rawRowCount: payload.total_results }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
'observations',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/** Decode one validated density tile without observation identifiers. */
|
||||
async function getGrid(params, options = {}) {
|
||||
if (!validInatGridTile(params)) throw new TypeError('Invalid grid tile');
|
||||
const query = observationFilters(params);
|
||||
return requestProjected(
|
||||
`${API_URL}/grid/${params.z}/${params.x}/${params.y}.grid.json?${query}`,
|
||||
(payload) => projectInatGrid(payload, params),
|
||||
'grid',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
getObservations,
|
||||
getGrid,
|
||||
/** Current provider-wide or resource-specific retry information. */
|
||||
cooldownState(resource) {
|
||||
if (now() < cooldownUntil)
|
||||
return {
|
||||
upstreamStatus: 429,
|
||||
retryAfterS: retrySeconds(),
|
||||
};
|
||||
const failure = failures.get(resource);
|
||||
if (failure && now() < failure.until)
|
||||
return {
|
||||
upstreamStatus: failure.status,
|
||||
retryAfterS: retrySeconds(failure.until),
|
||||
};
|
||||
return undefined;
|
||||
},
|
||||
/** The current UTC day's reserved upstream attempts and configured limit. */
|
||||
budgetState() {
|
||||
const value = currentBudget();
|
||||
return { ...value, budget: dailyLimit() };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createInatClient } from './client.js';
|
||||
import { syntheticGrid } from './grid.fixture.mjs';
|
||||
|
||||
const NOW = Date.parse('2026-11-04T18:05:00Z');
|
||||
const observation = (id, time) => ({
|
||||
id,
|
||||
geojson: { coordinates: [-122.3, 47.6] },
|
||||
quality_grade: 'research',
|
||||
time_observed_at: time,
|
||||
observed_on: time.slice(0, 10),
|
||||
// Upload age does not determine whether an observation is recent.
|
||||
created_at: '2026-11-04T18:00:00Z',
|
||||
taxon: { name: 'Synthetic taxon', iconic_taxon_name: 'Aves' },
|
||||
});
|
||||
|
||||
function clientFor(results) {
|
||||
return createInatClient({
|
||||
now: () => NOW,
|
||||
cacheDir: null,
|
||||
queueOptions: { intervalMs: 0 },
|
||||
warn() {},
|
||||
fetchImpl: async () =>
|
||||
Response.json({ results, total_results: results.length }),
|
||||
});
|
||||
}
|
||||
|
||||
test('iNaturalist drops ancient and boundary-expired observations uploaded today', async () => {
|
||||
const client = clientFor([
|
||||
observation(1, '2010-11-04T18:00:00Z'),
|
||||
observation(2, '2026-11-02T18:04:59Z'),
|
||||
observation(3, '2026-11-02T18:05:00Z'),
|
||||
]);
|
||||
const result = await client.getObservations({
|
||||
scope: 'global',
|
||||
taxa: 'all',
|
||||
observedD1: '2026-11-02',
|
||||
});
|
||||
assert.deepEqual(
|
||||
result.rows.map((row) => row.stableId),
|
||||
['inat:3'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a valid date-filtered page can be empty after the exact recency cutoff', async () => {
|
||||
const client = clientFor([observation(1, '2026-11-02T06:00:00Z')]);
|
||||
const result = await client.getObservations({
|
||||
scope: 'global',
|
||||
taxa: 'all',
|
||||
observedD1: '2026-11-02',
|
||||
});
|
||||
assert.deepEqual(result.rows, []);
|
||||
assert.equal(result.totalResults, 1);
|
||||
});
|
||||
|
||||
const params = {
|
||||
scope: 'view',
|
||||
box: { south: 47, west: -123, north: 48, east: -122 },
|
||||
taxa: 'all',
|
||||
observedD1: '2026-11-02',
|
||||
};
|
||||
|
||||
test('observations and tiles share pacing, and observation priority wins a cold burst', async () => {
|
||||
let at = 0;
|
||||
const calls = [];
|
||||
const client = createInatClient({
|
||||
cacheDir: null,
|
||||
now: () => NOW + at,
|
||||
queueOptions: {
|
||||
now: () => at,
|
||||
wait: async (ms) => {
|
||||
at += ms;
|
||||
},
|
||||
},
|
||||
fetchImpl: async (url) => {
|
||||
const isGrid = new URL(url).pathname.includes('/grid/');
|
||||
calls.push([isGrid ? 'grid' : 'observations', at]);
|
||||
return Response.json(
|
||||
isGrid ? syntheticGrid() : { results: [], total_results: 0 },
|
||||
);
|
||||
},
|
||||
});
|
||||
const tiles = Array.from({ length: 3 }, (_, x) =>
|
||||
client.getGrid({ ...params, z: 2, x, y: 1 }),
|
||||
);
|
||||
await Promise.all([...tiles, client.getObservations(params)]);
|
||||
assert.deepEqual(calls, [
|
||||
['observations', 0],
|
||||
['grid', 0],
|
||||
['grid', 0],
|
||||
['grid', 0],
|
||||
]);
|
||||
assert.equal(client.budgetState().count, 4);
|
||||
});
|
||||
|
||||
test('upstream status and safe failure reasons are logged without upstream messages or URLs', async () => {
|
||||
for (const [response, expected] of [
|
||||
[
|
||||
() =>
|
||||
new Response('request-secret', {
|
||||
status: 503,
|
||||
statusText: 'request-secret',
|
||||
}),
|
||||
/status=503 reason=http/,
|
||||
],
|
||||
[
|
||||
() => Promise.reject(new DOMException('request-secret', 'TimeoutError')),
|
||||
/status=none reason=timeout/,
|
||||
],
|
||||
[
|
||||
() => Response.json({ unexpected: 'request-secret' }),
|
||||
/status=200 reason=invalid-payload/,
|
||||
],
|
||||
]) {
|
||||
const warnings = [];
|
||||
const client = createInatClient({
|
||||
cacheDir: null,
|
||||
now: () => NOW,
|
||||
queueOptions: { intervalMs: 0 },
|
||||
warn: (message) => warnings.push(message),
|
||||
fetchImpl: response,
|
||||
});
|
||||
await assert.rejects(client.getObservations(params), { status: 502 });
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], expected);
|
||||
assert.doesNotMatch(warnings[0], /request-secret|https?:|swlat|d1=/);
|
||||
}
|
||||
});
|
||||
|
||||
test('a dispatched abort is diagnosed, spends only its attempt, and leaves the next request healthy', async () => {
|
||||
const warnings = [];
|
||||
const controller = new AbortController();
|
||||
let started;
|
||||
const didStart = new Promise((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
let calls = 0;
|
||||
const client = createInatClient({
|
||||
cacheDir: null,
|
||||
now: () => NOW,
|
||||
queueOptions: { intervalMs: 0 },
|
||||
warn: (message) => warnings.push(message),
|
||||
fetchImpl: async (url, { signal }) => {
|
||||
calls += 1;
|
||||
if (calls > 1) return Response.json({ results: [], total_results: 0 });
|
||||
started();
|
||||
return new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const pending = client.getObservations(params, { signal: controller.signal });
|
||||
const failed = assert.rejects(pending, { name: 'AbortError' });
|
||||
await didStart;
|
||||
controller.abort();
|
||||
await failed;
|
||||
assert.match(warnings[0], /resource=observations status=none reason=abort/);
|
||||
await client.getObservations(params);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Build a synthetic UTFGrid with logical cell counts and repeated hit pixels. */
|
||||
export function syntheticGrid({
|
||||
z = 2,
|
||||
x = 1,
|
||||
y = 1,
|
||||
cells = [
|
||||
{ row: 1, column: 51, count: 6, iconic: 3 },
|
||||
{ row: 3, column: 49, count: 12, iconic: 47170 },
|
||||
],
|
||||
} = {}) {
|
||||
const pixels = Array.from({ length: 64 }, () => Array(64).fill(' '));
|
||||
const keys = [''];
|
||||
const data = {};
|
||||
cells.forEach((cell, index) => {
|
||||
const key = String(900_000_000 + index);
|
||||
keys.push(key);
|
||||
const value = index + 1;
|
||||
let code = value + 32;
|
||||
if (code >= 34) code += 1;
|
||||
if (code >= 92) code += 1;
|
||||
const character = String.fromCharCode(code);
|
||||
for (const [row, column] of [
|
||||
[cell.row, cell.column],
|
||||
[cell.row - 1, cell.column],
|
||||
[cell.row + 1, cell.column],
|
||||
[cell.row, cell.column - 1],
|
||||
[cell.row, cell.column + 1],
|
||||
]) {
|
||||
if (row >= 0 && row < 64 && column >= 0 && column < 64)
|
||||
pixels[row][column] = character;
|
||||
}
|
||||
data[key] = {
|
||||
captive: null,
|
||||
cellCount: cell.count,
|
||||
geoprivacy: null,
|
||||
id: Number(key),
|
||||
latitude:
|
||||
(Math.atan(
|
||||
Math.sinh(Math.PI * (1 - 2 * ((y + cell.row / 64) / 2 ** z))),
|
||||
) *
|
||||
180) /
|
||||
Math.PI,
|
||||
longitude: ((x + cell.column / 64) / 2 ** z) * 360 - 180,
|
||||
private_location: null,
|
||||
quality_grade: 'needs_id',
|
||||
'taxon.iconic_taxon_id': cell.iconic,
|
||||
uuid: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
|
||||
};
|
||||
});
|
||||
return { grid: pixels.map((row) => row.join('')), keys, data };
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// iNaturalist's operational /v1/grid/{z}/{x}/{y}.grid.json endpoint is not
|
||||
// covered by its v1 Swagger contract. Validate the entire UTFGrid shape before
|
||||
// accepting any cell; callers preserve the last good sample on schema drift.
|
||||
import {
|
||||
wildlifeGridCellId,
|
||||
WILDLIFE_GRID_MAX_ZOOM,
|
||||
} from '../../../src/layers/wildlifeSightings/gridViewport.js';
|
||||
|
||||
const INAT_GRID_SIZE = 64;
|
||||
export const INAT_GRID_DECODE_MEASURE = 'wildlife:server:tile-decode';
|
||||
const grades = new Set(['research', 'needs_id', 'casual']);
|
||||
const object = (value) =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
/** Accept only integer XYZ coordinates within the supported zoom range. */
|
||||
export function validInatGridTile({ z, x, y } = {}) {
|
||||
return (
|
||||
Number.isInteger(z) &&
|
||||
z >= 0 &&
|
||||
z <= WILDLIFE_GRID_MAX_ZOOM &&
|
||||
Number.isInteger(x) &&
|
||||
Number.isInteger(y) &&
|
||||
x >= 0 &&
|
||||
y >= 0 &&
|
||||
x < 2 ** z &&
|
||||
y < 2 ** z
|
||||
);
|
||||
}
|
||||
|
||||
/** Pure allow-list projection. A UTFGrid key is an observation identifier and
|
||||
* its repeated pixels form a dilated hit area, not additional occupied cells.
|
||||
* Neither those keys nor representative/private fields cross this boundary. */
|
||||
export function projectInatGrid(payload, tile) {
|
||||
const start = `${INAT_GRID_DECODE_MEASURE}:start`;
|
||||
const end = `${INAT_GRID_DECODE_MEASURE}:end`;
|
||||
performance.mark(start);
|
||||
try {
|
||||
return decodeInatGrid(payload, tile);
|
||||
} finally {
|
||||
performance.mark(end);
|
||||
// Keep the latest sample inspectable without retaining every tile forever.
|
||||
// PerformanceObservers still receive each individual decode measurement.
|
||||
performance.clearMeasures(INAT_GRID_DECODE_MEASURE);
|
||||
performance.measure(INAT_GRID_DECODE_MEASURE, start, end);
|
||||
performance.clearMarks(start);
|
||||
performance.clearMarks(end);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeInatGrid(payload, tile) {
|
||||
const invalid = () => {
|
||||
throw new TypeError('Invalid iNaturalist UTFGrid');
|
||||
};
|
||||
if (
|
||||
!validInatGridTile(tile) ||
|
||||
!object(payload) ||
|
||||
!Array.isArray(payload.grid) ||
|
||||
payload.grid.length !== INAT_GRID_SIZE ||
|
||||
!payload.grid.every(
|
||||
(row) => typeof row === 'string' && row.length === INAT_GRID_SIZE,
|
||||
) ||
|
||||
!Array.isArray(payload.keys) ||
|
||||
!payload.keys.length ||
|
||||
payload.keys.length > INAT_GRID_SIZE ** 2 + 1 ||
|
||||
payload.keys[0] !== '' ||
|
||||
!payload.keys.every(
|
||||
(key) => typeof key === 'string' && key.length <= 128,
|
||||
) ||
|
||||
new Set(payload.keys).size !== payload.keys.length ||
|
||||
!object(payload.data)
|
||||
)
|
||||
invalid();
|
||||
const dataKeys = Object.keys(payload.data);
|
||||
if (dataKeys.length !== payload.keys.length - 1) invalid();
|
||||
for (const key of payload.keys.slice(1)) {
|
||||
if (!key || !Object.hasOwn(payload.data, key)) invalid();
|
||||
const value = payload.data[key];
|
||||
if (
|
||||
!object(value) ||
|
||||
!Number.isSafeInteger(value.cellCount) ||
|
||||
value.cellCount <= 0 ||
|
||||
!Number.isFinite(value.latitude) ||
|
||||
Math.abs(value.latitude) > 90 ||
|
||||
!Number.isFinite(value.longitude) ||
|
||||
Math.abs(value.longitude) > 180 ||
|
||||
!grades.has(value.quality_grade) ||
|
||||
!(
|
||||
value['taxon.iconic_taxon_id'] === null ||
|
||||
(Number.isSafeInteger(value['taxon.iconic_taxon_id']) &&
|
||||
value['taxon.iconic_taxon_id'] > 0)
|
||||
)
|
||||
)
|
||||
invalid();
|
||||
}
|
||||
const occupied = new Set();
|
||||
for (const line of payload.grid) {
|
||||
for (let column = 0; column < INAT_GRID_SIZE; column += 1) {
|
||||
let code = line.charCodeAt(column);
|
||||
if (code < 32 || code === 34 || code === 92) invalid();
|
||||
if (code >= 93) code -= 1;
|
||||
if (code >= 35) code -= 1;
|
||||
const index = code - 32;
|
||||
if (index < 0 || index >= payload.keys.length) invalid();
|
||||
if (index) occupied.add(payload.keys[index]);
|
||||
}
|
||||
}
|
||||
if (occupied.size !== dataKeys.length) invalid();
|
||||
const cells = new Map();
|
||||
for (const key of occupied) {
|
||||
const value = payload.data[key];
|
||||
const id = wildlifeGridCellId(value.latitude, value.longitude, tile.z);
|
||||
const cell = {
|
||||
id,
|
||||
cellCount: value.cellCount,
|
||||
lat: value.latitude,
|
||||
lon: value.longitude,
|
||||
};
|
||||
// Two different representatives claiming one logical cell are a contract
|
||||
// change; summing or choosing one silently would fabricate the count.
|
||||
if (cells.has(id)) invalid();
|
||||
cells.set(id, cell);
|
||||
}
|
||||
const rows = [...cells.values()];
|
||||
const totalResults = rows.reduce((total, cell) => total + cell.cellCount, 0);
|
||||
if (!Number.isSafeInteger(totalResults)) invalid();
|
||||
return { cells: rows, totalResults };
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { projectInatGrid } from './grid.js';
|
||||
import { syntheticGrid } from './grid.fixture.mjs';
|
||||
import { installInatProxy, NOW } from './proxy.fixture.mjs';
|
||||
import { createInatClient } from './client.js';
|
||||
|
||||
const tile = { z: 2, x: 1, y: 1 };
|
||||
const gridUrl = '/grid?z=2&x=1&y=1&taxa=Aves&quality=research';
|
||||
const install = (options, hook) => installInatProxy(gridUrl, options, hook);
|
||||
|
||||
test('UTFGrid projection validates the full shape and counts each logical occupied cell once', () => {
|
||||
const raw = syntheticGrid();
|
||||
const projected = projectInatGrid(raw, tile);
|
||||
assert.equal(raw.grid.length, 64);
|
||||
assert.ok(raw.grid.every((row) => row.length === 64));
|
||||
assert.equal(
|
||||
raw.grid.join('').split('!').length - 1,
|
||||
5,
|
||||
'one key is repeated by its hit area',
|
||||
);
|
||||
assert.equal(projected.cells.length, 2);
|
||||
assert.equal(projected.totalResults, 18);
|
||||
assert.deepEqual(projected.cells[0], {
|
||||
id: 'grid:2/1/1:1:51',
|
||||
cellCount: 6,
|
||||
lat: raw.data[raw.keys[1]].latitude,
|
||||
lon: raw.data[raw.keys[1]].longitude,
|
||||
});
|
||||
for (const cell of projected.cells)
|
||||
assert.deepEqual(Object.keys(cell).sort(), [
|
||||
'cellCount',
|
||||
'id',
|
||||
'lat',
|
||||
'lon',
|
||||
]);
|
||||
for (const privateField of [
|
||||
'900000000',
|
||||
'uuid',
|
||||
'geoprivacy',
|
||||
'private_location',
|
||||
'captive',
|
||||
'quality_grade',
|
||||
])
|
||||
assert.ok(!JSON.stringify(projected).includes(privateField));
|
||||
});
|
||||
|
||||
test('legitimate blank grid and null/unknown iconic ids remain safe', () => {
|
||||
assert.deepEqual(projectInatGrid(syntheticGrid({ cells: [] }), tile), {
|
||||
cells: [],
|
||||
totalResults: 0,
|
||||
});
|
||||
const raw = syntheticGrid({
|
||||
cells: [{ row: 1, column: 1, count: 1, iconic: null }],
|
||||
});
|
||||
const projected = projectInatGrid(raw, tile);
|
||||
assert.equal(projected.cells[0].cellCount, 1);
|
||||
raw.data[raw.keys[1]]['taxon.iconic_taxon_id'] = 999999;
|
||||
assert.deepEqual(projectInatGrid(raw, tile), projected);
|
||||
});
|
||||
|
||||
test('schema drift anywhere fails closed, including unused, malformed and orphan cells', () => {
|
||||
const changes = [
|
||||
(raw) => {
|
||||
raw.grid.pop();
|
||||
},
|
||||
(raw) => {
|
||||
raw.grid[0] = '';
|
||||
},
|
||||
(raw) => {
|
||||
raw.grid[0] = '~'.repeat(64);
|
||||
},
|
||||
(raw) => {
|
||||
raw.keys[0] = 'not-empty';
|
||||
},
|
||||
(raw) => {
|
||||
raw.keys.push(raw.keys[1]);
|
||||
},
|
||||
(raw) => {
|
||||
delete raw.data[raw.keys[1]];
|
||||
},
|
||||
(raw) => {
|
||||
raw.data.extra = {};
|
||||
},
|
||||
(raw) => {
|
||||
raw.data[raw.keys[1]].cellCount = -1;
|
||||
},
|
||||
(raw) => {
|
||||
raw.data[raw.keys[1]].latitude = '47';
|
||||
},
|
||||
(raw) => {
|
||||
raw.data[raw.keys[1]].longitude = 181;
|
||||
},
|
||||
(raw) => {
|
||||
raw.data[raw.keys[1]].quality_grade = 'new-grade';
|
||||
},
|
||||
(raw) => {
|
||||
delete raw.data[raw.keys[1]]['taxon.iconic_taxon_id'];
|
||||
},
|
||||
(raw) => {
|
||||
raw.data[raw.keys[1]]['taxon.iconic_taxon_id'] = 'Aves';
|
||||
},
|
||||
(raw) => {
|
||||
raw.grid = Array(64).fill(' '.repeat(64));
|
||||
},
|
||||
];
|
||||
for (const change of changes) {
|
||||
const raw = syntheticGrid();
|
||||
change(raw);
|
||||
assert.throws(
|
||||
() => projectInatGrid(raw, tile),
|
||||
/Invalid iNaturalist UTFGrid/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
test(`${hook}: fixed grid URL shares filters, caps and allow-list cache`, async () => {
|
||||
const calls = [];
|
||||
const cache = new Map();
|
||||
const request = install(
|
||||
{
|
||||
cache,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push(new URL(url));
|
||||
assert.equal(options.redirect, 'error');
|
||||
assert.ok(options.signal instanceof AbortSignal);
|
||||
assert.match(
|
||||
options.headers['User-Agent'],
|
||||
/^gods-eye-view-wildlife-sightings-proxy/,
|
||||
);
|
||||
return Response.json(syntheticGrid());
|
||||
},
|
||||
},
|
||||
hook,
|
||||
);
|
||||
assert.equal(calls.length, 0);
|
||||
const response = await request(
|
||||
`${gridUrl}&url=https://attacker.test&per_page=999`,
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.body.cells.length, 2);
|
||||
assert.equal(response.body.fetchedAt, NOW);
|
||||
assert.equal(response.headers['Cache-Control'], 'no-store');
|
||||
assert.equal(
|
||||
calls[0].origin + calls[0].pathname,
|
||||
'https://api.inaturalist.org/v1/grid/2/1/1.grid.json',
|
||||
);
|
||||
assert.deepEqual(Object.fromEntries(calls[0].searchParams), {
|
||||
verifiable: 'true',
|
||||
captive: 'false',
|
||||
d1: '2026-11-02',
|
||||
photos: 'true',
|
||||
quality_grade: 'research',
|
||||
iconic_taxa: 'Aves',
|
||||
});
|
||||
await request(gridUrl);
|
||||
assert.equal(calls.length, 1, 'fresh stable tile/filter key is cached');
|
||||
assert.ok(!JSON.stringify([...cache]).includes('private_location'));
|
||||
assert.ok(!JSON.stringify([...cache]).includes('900000000'));
|
||||
const whale = await request('/grid?z=2&x=1&y=1&taxa=whales&confirmed=true');
|
||||
assert.equal(whale.status, 200);
|
||||
assert.equal(calls[1].searchParams.get('taxon_id'), '152871');
|
||||
assert.equal(calls[1].searchParams.get('d1'), '2026-10-28');
|
||||
assert.equal(calls[1].searchParams.has('photos'), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('grid admission rejects cross-site/method/path/tile before acquisition', async () => {
|
||||
const request = install({
|
||||
fetchImpl() {
|
||||
assert.fail('invalid grid request acquired');
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await request(gridUrl, {
|
||||
headers: {
|
||||
host: 'localhost:5173',
|
||||
origin: 'https://other.test',
|
||||
'sec-fetch-site': 'cross-site',
|
||||
},
|
||||
})
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
assert.equal((await request('/grid', { method: 'POST' })).status, 405);
|
||||
assert.equal((await request('/grid/extra')).status, 404);
|
||||
for (const query of [
|
||||
'',
|
||||
'z=2&x=4&y=0',
|
||||
'z=-1&x=0&y=0',
|
||||
'z=23&x=0&y=0',
|
||||
'z=2&x=1.5&y=0',
|
||||
'z=2&x=&y=0',
|
||||
'z=2&x=0&y=4',
|
||||
]) {
|
||||
const result = await request(`/grid?${query}`);
|
||||
assert.equal(result.status, 400, query);
|
||||
assert.equal(result.body.error, 'invalid_tile');
|
||||
}
|
||||
});
|
||||
|
||||
test('grid refresh single-flight, stale fallback and hour-key expiry keep the original age', async () => {
|
||||
let time = NOW;
|
||||
let calls = 0;
|
||||
let resolve;
|
||||
let mode = 'pending';
|
||||
const request = install({
|
||||
now: () => time,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
if (mode === 'pending')
|
||||
return new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
if (mode === 'bad') return Response.json({ grid: [] });
|
||||
return Response.json(syntheticGrid());
|
||||
},
|
||||
});
|
||||
const first = request();
|
||||
const second = request();
|
||||
for (let i = 0; i < 100 && !resolve; i += 1) await new Promise(setImmediate);
|
||||
assert.equal(calls, 1);
|
||||
resolve(Response.json(syntheticGrid()));
|
||||
assert.equal((await first).status, 200);
|
||||
assert.equal((await second).status, 200);
|
||||
time += 900_001;
|
||||
mode = 'bad';
|
||||
const stale = await request();
|
||||
assert.equal(stale.status, 200);
|
||||
assert.equal(stale.body.stale, true);
|
||||
assert.equal(stale.body.fetchedAt, NOW);
|
||||
assert.equal(stale.headers['X-Data-Stale'], 'true');
|
||||
time = NOW + 3_600_001;
|
||||
const expired = await request();
|
||||
assert.equal(expired.status, 502);
|
||||
assert.equal(expired.body.error, 'inaturalist_unavailable');
|
||||
});
|
||||
|
||||
test('grid and observations share the upstream cooldown and daily reservations', async () => {
|
||||
let calls = 0;
|
||||
const client = createInatClient({
|
||||
now: () => NOW,
|
||||
cacheDir: null,
|
||||
queueOptions: { intervalMs: 0 },
|
||||
warn() {},
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return new Response('', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '120' },
|
||||
});
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
client.getGrid({ ...tile, taxa: 'all', observedD1: '2026-11-02' }),
|
||||
(error) => error.status === 429 && error.retryAfterS === 120,
|
||||
);
|
||||
await assert.rejects(
|
||||
client.getObservations({
|
||||
scope: 'global',
|
||||
taxa: 'all',
|
||||
observedD1: '2026-11-02',
|
||||
}),
|
||||
(error) => error.status === 429,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(client.budgetState().count, 1);
|
||||
});
|
||||
|
||||
test('grid body cap and upstream failures return fixed errors; only real 5xx cools down', async () => {
|
||||
for (const [response, retryAfter] of [
|
||||
[() => new Response('x'.repeat(8 * 1024 * 1024 + 1)), undefined],
|
||||
[() => new Response('', { status: 503 }), '15'],
|
||||
]) {
|
||||
const request = install({ fetchImpl: async () => response() });
|
||||
const result = await request();
|
||||
assert.equal(result.status, 502);
|
||||
assert.deepEqual(result.body, { error: 'inaturalist_unavailable' });
|
||||
assert.equal(result.headers['Retry-After'], retryAfter);
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed tile preserves last good cells and cannot send unrelated observations into 502', async () => {
|
||||
let at = NOW;
|
||||
let failGrid = false;
|
||||
const calls = [];
|
||||
const warnings = [];
|
||||
const request = install({
|
||||
now: () => at,
|
||||
warn: (message) => warnings.push(message),
|
||||
fetchImpl: async (url) => {
|
||||
const isGrid = new URL(url).pathname.includes('/grid/');
|
||||
calls.push(isGrid ? 'grid' : 'observations');
|
||||
if (isGrid && failGrid)
|
||||
return new Response('private upstream text', {
|
||||
status: 503,
|
||||
headers: { 'Retry-After': '45' },
|
||||
});
|
||||
return Response.json(
|
||||
isGrid ? syntheticGrid() : { results: [], total_results: 0 },
|
||||
);
|
||||
},
|
||||
});
|
||||
const first = await request();
|
||||
at += 900_001;
|
||||
failGrid = true;
|
||||
const failed = await request();
|
||||
assert.equal(failed.status, 200);
|
||||
assert.equal(failed.headers['X-Data-Stale'], 'true');
|
||||
assert.equal(failed.body.stale, true);
|
||||
assert.equal(failed.body.fetchedAt, first.body.fetchedAt);
|
||||
assert.deepEqual(failed.body.cells, first.body.cells);
|
||||
assert.equal(
|
||||
(await request('/observations?scope=global&taxa=whales')).status,
|
||||
200,
|
||||
);
|
||||
assert.deepEqual(calls, ['grid', 'grid', 'observations']);
|
||||
assert.match(warnings[0], /resource=grid status=503 reason=http/);
|
||||
assert.doesNotMatch(warnings[0], /private upstream text/);
|
||||
// The tile cooldown also preserves its data without another upstream try.
|
||||
const cooling = await request();
|
||||
assert.equal(cooling.body.stale, true);
|
||||
assert.equal(cooling.headers['Retry-After'], '45');
|
||||
assert.equal(cooling.headers['X-Upstream-Status'], '503');
|
||||
assert.equal(calls.length, 3);
|
||||
at += 45_000;
|
||||
failGrid = false;
|
||||
const refreshing = await request();
|
||||
assert.equal(refreshing.body.stale, true);
|
||||
await new Promise(setImmediate);
|
||||
const recovered = await request();
|
||||
assert.equal(recovered.status, 200);
|
||||
assert.equal(recovered.body.stale, undefined);
|
||||
assert.equal(
|
||||
calls.length,
|
||||
4,
|
||||
'a failed queue task does not poison later tile work',
|
||||
);
|
||||
});
|
||||
|
||||
test('twelve queued tiles leave observation capacity and give the next dispatch to observations', async () => {
|
||||
const calls = [];
|
||||
let release;
|
||||
const request = install({
|
||||
fetchImpl: (url) => {
|
||||
const path = new URL(url).pathname;
|
||||
calls.push(path);
|
||||
if (calls.length === 1)
|
||||
return new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return Response.json(
|
||||
path.includes('/grid/')
|
||||
? syntheticGrid()
|
||||
: { results: [], total_results: 0 },
|
||||
);
|
||||
},
|
||||
});
|
||||
const first = request('/grid?z=4&x=0&y=1');
|
||||
for (let i = 0; i < 100 && !release; i += 1) await new Promise(setImmediate);
|
||||
assert.ok(release);
|
||||
const tiles = Array.from({ length: 11 }, (_, i) =>
|
||||
request(`/grid?z=4&x=${i + 1}&y=1`),
|
||||
);
|
||||
const observation = request('/observations?scope=global');
|
||||
const queuedTile = request('/grid?z=4&x=12&y=1');
|
||||
release(Response.json(syntheticGrid()));
|
||||
const results = await Promise.all([first, observation, ...tiles, queuedTile]);
|
||||
assert.ok(results.every((result) => result.status === 200));
|
||||
assert.equal(calls.length, 14);
|
||||
assert.equal(calls[1], '/v2/observations');
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
import { createInatClient } from './client.js';
|
||||
import { coalesceProxyRequest } from '../common/http.js';
|
||||
import { admitSameSiteKeyless } from '../common/same-site.js';
|
||||
import { requiredFiniteQueryNumber } from '../common/query.js';
|
||||
import { snapWildlifeBox } from '../../../src/layers/wildlifeSightings/bounds.js';
|
||||
import { validInatGridTile } from './grid.js';
|
||||
|
||||
// The upstream advertises five-minute freshness; global samples cost less often.
|
||||
const TTL_MS = 300_000;
|
||||
const GLOBAL_TTL_MS = 600_000;
|
||||
const INAT_GRID_CACHE_TTL_MS = 15 * 60_000;
|
||||
const STALE_MAX_MS = 3_600_000;
|
||||
const INAT_CACHE_MAX = 400;
|
||||
const HOUR_MS = 3_600_000;
|
||||
const TAXA = Object.freeze([
|
||||
'all',
|
||||
'Aves',
|
||||
'Mammalia',
|
||||
'Insecta',
|
||||
'Plantae',
|
||||
'whales',
|
||||
]);
|
||||
|
||||
function requestParams(pathname, query, nowMs) {
|
||||
const grid = pathname === '/grid';
|
||||
const scope = grid ? 'grid' : (query.get('scope') ?? 'view');
|
||||
if (!grid && scope !== 'view' && scope !== 'global')
|
||||
return { error: 'invalid_scope' };
|
||||
const quality =
|
||||
query.get('quality') === 'research' ||
|
||||
(grid && query.get('confirmed') === 'true')
|
||||
? 'research'
|
||||
: 'all';
|
||||
const taxa = TAXA.includes(query.get('taxa')) ? query.get('taxa') : 'all';
|
||||
let box = null;
|
||||
let tile;
|
||||
if (grid) {
|
||||
tile = Object.fromEntries(
|
||||
['z', 'x', 'y'].map((name) => [
|
||||
name,
|
||||
requiredFiniteQueryNumber(query, name),
|
||||
]),
|
||||
);
|
||||
if (!validInatGridTile(tile)) return { error: 'invalid_tile' };
|
||||
} else if (scope === 'view') {
|
||||
const south = requiredFiniteQueryNumber(query, 'south');
|
||||
const west = requiredFiniteQueryNumber(query, 'west');
|
||||
const north = requiredFiniteQueryNumber(query, 'north');
|
||||
const east = requiredFiniteQueryNumber(query, 'east');
|
||||
box = snapWildlifeBox({ south, west, north, east });
|
||||
if (!box) return { error: 'invalid_bbox' };
|
||||
}
|
||||
const windowMs = (taxa === 'whales' ? 7 * 24 : 48) * HOUR_MS;
|
||||
const windowKey = new Date(Math.floor((nowMs - windowMs) / HOUR_MS) * HOUR_MS)
|
||||
.toISOString()
|
||||
.replace('.000Z', 'Z');
|
||||
const bounds = box ? `${box.south},${box.west},${box.north},${box.east}` : '';
|
||||
const baseKey = grid
|
||||
? `grid:v1:${tile.z}/${tile.x}/${tile.y}:${quality}:${taxa}`
|
||||
: `v1:${scope}:${bounds}:${quality}:${taxa}`;
|
||||
return {
|
||||
scope,
|
||||
...(grid
|
||||
? { ...tile, waitForFresh: query.get('refresh') === 'wait' }
|
||||
: { box }),
|
||||
quality,
|
||||
taxa,
|
||||
observedD1: windowKey.slice(0, 10),
|
||||
baseKey,
|
||||
key: `${baseKey}:${windowKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** A shared-provider route for dev and preview; acquisition starts on demand. */
|
||||
export function inaturalistProxy({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
now = () => Date.now(),
|
||||
cacheDir,
|
||||
cache = new Map(),
|
||||
warn = (...args) => console.warn(...args),
|
||||
queueOptions,
|
||||
} = {}) {
|
||||
const inFlight = new Map();
|
||||
const consumers = new Map();
|
||||
let client = null;
|
||||
|
||||
function age(entry) {
|
||||
if (!entry) return Infinity;
|
||||
const elapsed = now() - entry.savedAt;
|
||||
return elapsed < 0 ? Infinity : elapsed;
|
||||
}
|
||||
|
||||
function joinFlight(promise, group, signal) {
|
||||
group.count += 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
let released = false;
|
||||
function release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
signal?.removeEventListener('abort', abort);
|
||||
group.count -= 1;
|
||||
if (!group.count && !group.background && !group.settled) {
|
||||
group.controller.abort();
|
||||
// A fresh consumer must not join an abandoned queued generation.
|
||||
if (inFlight.get(group.key) === promise) inFlight.delete(group.key);
|
||||
if (consumers.get(group.key) === group) consumers.delete(group.key);
|
||||
}
|
||||
}
|
||||
function abort() {
|
||||
release();
|
||||
reject(signal.reason);
|
||||
}
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) abort();
|
||||
promise.then(
|
||||
(value) => {
|
||||
release();
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
release();
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function refresh(params, background = false) {
|
||||
// Global samples share pending refreshes across hourly window changes.
|
||||
const flightKey = params.scope === 'global' ? params.baseKey : params.key;
|
||||
let group = consumers.get(flightKey);
|
||||
if (!group) {
|
||||
group = {
|
||||
key: flightKey,
|
||||
controller: new AbortController(),
|
||||
count: 0,
|
||||
settled: false,
|
||||
background,
|
||||
};
|
||||
consumers.set(flightKey, group);
|
||||
}
|
||||
group.background ||= background;
|
||||
const { promise, shared } = coalesceProxyRequest(
|
||||
inFlight,
|
||||
flightKey,
|
||||
async () => {
|
||||
client ??= createInatClient({
|
||||
fetchImpl,
|
||||
now,
|
||||
cacheDir,
|
||||
warn,
|
||||
queueOptions,
|
||||
});
|
||||
const options = { signal: group.controller.signal };
|
||||
const result = await (params.scope === 'grid'
|
||||
? client.getGrid(params, options)
|
||||
: client.getObservations(params, options));
|
||||
const value = {
|
||||
fetchedAt: now(),
|
||||
...(params.scope === 'grid'
|
||||
? {}
|
||||
: { scope: params.scope, box: params.box }),
|
||||
...result,
|
||||
};
|
||||
cache.delete(params.key);
|
||||
cache.set(params.key, {
|
||||
value,
|
||||
savedAt: now(),
|
||||
baseKey: params.baseKey,
|
||||
});
|
||||
while (cache.size > INAT_CACHE_MAX)
|
||||
cache.delete(cache.keys().next().value);
|
||||
return value;
|
||||
},
|
||||
);
|
||||
if (!shared) {
|
||||
const settle = () => {
|
||||
group.settled = true;
|
||||
if (consumers.get(flightKey) === group) consumers.delete(flightKey);
|
||||
};
|
||||
void promise.then(settle, settle);
|
||||
}
|
||||
return { promise, group };
|
||||
}
|
||||
|
||||
async function acquire(params, signal) {
|
||||
signal?.throwIfAborted();
|
||||
for (const [key, entry] of cache) {
|
||||
if (age(entry) > STALE_MAX_MS) cache.delete(key);
|
||||
}
|
||||
const current = cache.get(params.key);
|
||||
const ttl =
|
||||
params.scope === 'grid'
|
||||
? INAT_GRID_CACHE_TTL_MS
|
||||
: params.scope === 'global'
|
||||
? GLOBAL_TTL_MS
|
||||
: TTL_MS;
|
||||
if (current && age(current) < ttl) {
|
||||
cache.delete(params.key);
|
||||
cache.set(params.key, current);
|
||||
return { value: current.value, stale: false };
|
||||
}
|
||||
// Window-hour rollover can reuse prior cells without claiming a new age.
|
||||
let previous = current;
|
||||
let previousKey = params.key;
|
||||
if (!previous) {
|
||||
for (const [key, entry] of cache) {
|
||||
if (
|
||||
entry.baseKey === params.baseKey &&
|
||||
(!previous || entry.savedAt > previous.savedAt)
|
||||
) {
|
||||
previous = entry;
|
||||
previousKey = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (params.scope === 'global' && age(previous) < GLOBAL_TTL_MS)
|
||||
return { value: previous.value, stale: false };
|
||||
if (
|
||||
params.scope === 'grid' &&
|
||||
!params.waitForFresh &&
|
||||
age(previous) <= STALE_MAX_MS
|
||||
) {
|
||||
cache.delete(previousKey);
|
||||
cache.set(previousKey, previous);
|
||||
// Retain one background consumer after this HTTP response closes. A stale
|
||||
// tile is useful immediately; waiting for the token queue defeats SWR.
|
||||
const cooling = client?.cooldownState('grid');
|
||||
if (!cooling) {
|
||||
const { promise } = refresh(params, true);
|
||||
void promise.catch(() => {});
|
||||
}
|
||||
return {
|
||||
value: previous.value,
|
||||
stale: true,
|
||||
...cooling,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const { promise, group } = refresh(params);
|
||||
return { value: await joinFlight(promise, group, signal), stale: false };
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
if (age(previous) <= STALE_MAX_MS)
|
||||
return {
|
||||
value: previous.value,
|
||||
stale: true,
|
||||
retryAfterS: error.retryAfterS,
|
||||
upstreamStatus: error.upstreamStatus,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handler(req, res) {
|
||||
const json = (
|
||||
status,
|
||||
value,
|
||||
{ stale = false, retryAfterS, upstreamStatus } = {},
|
||||
) => {
|
||||
if (res.destroyed) return;
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
...(status === 405 ? { Allow: 'GET' } : {}),
|
||||
...(Number.isFinite(retryAfterS)
|
||||
? { 'Retry-After': String(retryAfterS) }
|
||||
: {}),
|
||||
...(upstreamStatus
|
||||
? { 'X-Upstream-Status': String(upstreamStatus) }
|
||||
: {}),
|
||||
...(stale ? { 'X-Data-Stale': 'true' } : {}),
|
||||
});
|
||||
res.end(JSON.stringify(value));
|
||||
};
|
||||
if (admitSameSiteKeyless(req, res)) return;
|
||||
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
|
||||
const url = String(req.url || '/');
|
||||
const queryAt = url.indexOf('?');
|
||||
const pathname = queryAt < 0 ? url : url.slice(0, queryAt);
|
||||
if (pathname !== '/observations' && pathname !== '/grid')
|
||||
return json(404, { error: 'unknown_route' });
|
||||
const params = requestParams(
|
||||
pathname,
|
||||
new URLSearchParams(queryAt < 0 ? '' : url.slice(queryAt + 1)),
|
||||
now(),
|
||||
);
|
||||
if (params.error) return json(400, { error: params.error });
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
// IncomingMessage.close also fires after a complete request body. The
|
||||
// response close identifies the abandoned browser fetch, while aborted
|
||||
// covers a request cut short before it reached the response stage.
|
||||
req.once?.('aborted', abort);
|
||||
res.once?.('close', abort);
|
||||
try {
|
||||
const { value, stale, retryAfterS, upstreamStatus } = await acquire(
|
||||
params,
|
||||
controller.signal,
|
||||
);
|
||||
return json(200, stale ? { ...value, stale: true } : value, {
|
||||
stale,
|
||||
retryAfterS,
|
||||
upstreamStatus,
|
||||
});
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
const status =
|
||||
error.code === 'inaturalist_budget_exhausted'
|
||||
? 503
|
||||
: error.status === 429
|
||||
? 429
|
||||
: 502;
|
||||
const code =
|
||||
error.code === 'inaturalist_budget_exhausted'
|
||||
? error.code
|
||||
: status === 429
|
||||
? 'inaturalist_rate_limited'
|
||||
: 'inaturalist_unavailable';
|
||||
return json(
|
||||
status,
|
||||
{ error: code },
|
||||
{
|
||||
retryAfterS: error.retryAfterS,
|
||||
upstreamStatus: error.upstreamStatus,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
req.removeListener?.('aborted', abort);
|
||||
res.removeListener?.('close', abort);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'inaturalist',
|
||||
configureServer({ middlewares }) {
|
||||
middlewares.use('/api/inaturalist', handler);
|
||||
},
|
||||
configurePreviewServer({ middlewares }) {
|
||||
middlewares.use('/api/inaturalist', handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,893 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { installInatProxy, NOW } from './proxy.fixture.mjs';
|
||||
import { createInatClient, inatRetryCooldownMs } from './client.js';
|
||||
|
||||
const observation = (id = 1) => ({
|
||||
id,
|
||||
uri: `https://www.inaturalist.org/observations/${id}`,
|
||||
geojson: { coordinates: [-122.351, 47.613] },
|
||||
time_observed_at: '2026-11-04T17:00:00Z',
|
||||
observed_on: '2026-11-04',
|
||||
created_at: '2026-11-04T17:01:00Z',
|
||||
quality_grade: 'research',
|
||||
license_code: 'cc-by',
|
||||
positional_accuracy: 12,
|
||||
faves_count: 2,
|
||||
num_identification_agreements: 3,
|
||||
taxon: {
|
||||
name: 'Calypte anna',
|
||||
preferred_common_name: "Anna's hummingbird",
|
||||
iconic_taxon_name: 'Aves',
|
||||
},
|
||||
photos: [
|
||||
{
|
||||
url: 'https://inaturalist-open-data.s3.amazonaws.com/photos/123/square.jpeg',
|
||||
license_code: 'cc-by',
|
||||
attribution: 'Synthetic photographer, CC BY',
|
||||
},
|
||||
],
|
||||
user: { login: 'must-never-leave-server' },
|
||||
});
|
||||
const envelope = (rows = [observation()], total = rows.length) => ({
|
||||
total_results: total,
|
||||
page: 1,
|
||||
per_page: 200,
|
||||
results: rows,
|
||||
});
|
||||
const boxUrl = (index = 0, extra = '') =>
|
||||
`/observations?south=47.51&west=${-122.49 + index * 0.2}&north=47.69&east=${-122.31 + index * 0.2}${extra}`;
|
||||
|
||||
function install(options = {}, hook = 'configureServer') {
|
||||
return installInatProxy(
|
||||
boxUrl(),
|
||||
{
|
||||
// Serial dispatch makes queued cancellation and coalescing observable.
|
||||
queueOptions: { intervalMs: 0, windowMs: 0, maxInFlight: 1 },
|
||||
...options,
|
||||
},
|
||||
hook,
|
||||
);
|
||||
}
|
||||
|
||||
async function until(check) {
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
if (check()) return;
|
||||
await new Promise(setImmediate);
|
||||
}
|
||||
assert.fail('expected fake upstream calls were not dispatched');
|
||||
}
|
||||
|
||||
for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
test(`${hook}: actual v2 envelope, fixed query, projection and privacy`, async () => {
|
||||
const calls = [];
|
||||
const cache = new Map();
|
||||
const request = install(
|
||||
{
|
||||
cache,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push(new URL(url));
|
||||
assert.equal(options.redirect, 'error');
|
||||
assert.ok(options.signal instanceof AbortSignal);
|
||||
assert.equal(options.headers.Accept, 'application/json');
|
||||
assert.equal(
|
||||
options.headers['User-Agent'],
|
||||
'gods-eye-view-wildlife-sightings-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
);
|
||||
return Response.json(envelope());
|
||||
},
|
||||
},
|
||||
hook,
|
||||
);
|
||||
assert.equal(calls.length, 0, 'construction and mounting do not acquire');
|
||||
const result = await request(
|
||||
boxUrl(0, '&url=https://attacker.example&per_page=999'),
|
||||
);
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(result.headers['Cache-Control'], 'no-store');
|
||||
assert.equal(result.body.rows[0].stableId, 'inat:1');
|
||||
assert.equal(result.body.fetchedAt, NOW);
|
||||
assert.equal(result.body.scope, 'view');
|
||||
assert.deepEqual(result.body.box, {
|
||||
south: 47.5,
|
||||
west: -122.5,
|
||||
north: 47.7,
|
||||
east: -122.3,
|
||||
});
|
||||
const query = calls[0].searchParams;
|
||||
assert.equal(
|
||||
calls[0].origin + calls[0].pathname,
|
||||
'https://api.inaturalist.org/v2/observations',
|
||||
);
|
||||
// Only the projected fields are requested, never user or identification data.
|
||||
assert.match(query.get('fields'), /^\(id:!t,uri:!t,.*photos:\(url:!t/);
|
||||
assert.doesNotMatch(query.get('fields'), /user|identifications/);
|
||||
assert.equal(query.get('swlat'), '47.5');
|
||||
assert.equal(query.get('swlng'), '-122.5');
|
||||
assert.equal(query.get('nelat'), '47.7');
|
||||
assert.equal(query.get('nelng'), '-122.3');
|
||||
assert.equal(query.get('d1'), '2026-11-02');
|
||||
assert.equal(query.has('created_d1'), false);
|
||||
assert.equal(query.get('per_page'), '200');
|
||||
assert.equal(query.get('verifiable'), 'true');
|
||||
assert.equal(query.get('photos'), 'true');
|
||||
assert.equal(query.get('captive'), 'false');
|
||||
assert.equal(query.get('order_by'), 'random');
|
||||
assert.equal(query.has('order'), false);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(result.body),
|
||||
/must-never-leave-server|"user"/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify([...cache.values()]),
|
||||
/must-never-leave-server|"user"/,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('gate, method, route and query validation run before quota, cache or upstream', async () => {
|
||||
const cache = new Map();
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
cache,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return Response.json(envelope());
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < 32; i += 1) {
|
||||
const response = await request('/observations?scope=global', {
|
||||
headers: {
|
||||
host: 'localhost:5173',
|
||||
origin: 'https://foreign.example',
|
||||
'sec-fetch-site': 'cross-site',
|
||||
},
|
||||
});
|
||||
assert.equal(response.status, 403);
|
||||
}
|
||||
const method = await request('/observations', { method: 'POST' });
|
||||
assert.equal(method.status, 405);
|
||||
assert.equal(method.headers.Allow, 'GET');
|
||||
for (const route of ['/', '/unknown', '/observations/extra', '//[invalid'])
|
||||
assert.equal((await request(route)).body.error, 'unknown_route');
|
||||
assert.equal(
|
||||
(await request('/observations?scope=planet')).body.error,
|
||||
'invalid_scope',
|
||||
);
|
||||
for (const query of [
|
||||
'',
|
||||
'south=NaN&west=0&north=1&east=1',
|
||||
'south=&west=0&north=1&east=1',
|
||||
'south=0&west=0&north=0&east=1',
|
||||
'south=0&west=1&north=1&east=0',
|
||||
'south=-91&west=0&north=1&east=1',
|
||||
'south=0&west=-181&north=1&east=1',
|
||||
'south=0&west=0&north=41&east=1',
|
||||
'south=0&west=0&north=1&east=41',
|
||||
]) {
|
||||
const result = await request(`/observations?${query}`);
|
||||
assert.equal(result.status, 400, query);
|
||||
assert.equal(result.body.error, 'invalid_bbox', query);
|
||||
}
|
||||
assert.equal(cache.size, 0);
|
||||
assert.equal(calls, 0);
|
||||
const allowed = await request('/observations?scope=global', {
|
||||
headers: {
|
||||
host: 'gev.example',
|
||||
origin: 'https://gev.example',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-forwarded-proto': 'https',
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
},
|
||||
});
|
||||
assert.equal(allowed.status, 200);
|
||||
});
|
||||
|
||||
test('cached requests never exhaust a local request admission limiter', async () => {
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return Response.json(envelope());
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < 30; i += 1) assert.equal((await request()).status, 200);
|
||||
const cached = await request();
|
||||
assert.equal(cached.status, 200);
|
||||
assert.equal(cached.headers['Retry-After'], undefined);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('proxy rejects oversized snapped spans before acquisition and preserves canonical client boxes', async () => {
|
||||
const urls = [];
|
||||
const request = install({
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(new URL(url));
|
||||
return Response.json(envelope());
|
||||
},
|
||||
});
|
||||
for (const query of [
|
||||
'south=10.2&west=0&north=50.1&east=1',
|
||||
'south=0&west=10.2&north=1&east=50.1',
|
||||
]) {
|
||||
const result = await request(`/observations?${query}`);
|
||||
assert.equal(result.status, 400);
|
||||
assert.equal(result.body.error, 'invalid_bbox');
|
||||
}
|
||||
assert.equal(urls.length, 0);
|
||||
const canonical =
|
||||
'/observations?south=47.4&west=-122.3&north=47.6&east=-122.1';
|
||||
const first = await request(canonical);
|
||||
assert.equal(first.status, 200);
|
||||
assert.deepEqual(first.body.box, {
|
||||
south: 47.4,
|
||||
west: -122.3,
|
||||
north: 47.6,
|
||||
east: -122.1,
|
||||
});
|
||||
assert.equal(urls[0].searchParams.get('swlat'), '47.4');
|
||||
assert.equal(urls[0].searchParams.get('swlng'), '-122.3');
|
||||
const second = await request(canonical);
|
||||
assert.deepEqual(second.body, first.body);
|
||||
assert.equal(urls.length, 1, 'canonical boxes share their cache key');
|
||||
});
|
||||
|
||||
test('view snapping, whales, research and global queries obey the requested policy', async () => {
|
||||
const urls = [];
|
||||
const request = install({
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(new URL(url));
|
||||
return Response.json(envelope());
|
||||
},
|
||||
});
|
||||
await request(
|
||||
'/observations?south=40.2&west=-123.7&north=46.1&east=-121.4&quality=research&taxa=Aves',
|
||||
);
|
||||
assert.equal(urls[0].searchParams.get('swlat'), '40');
|
||||
assert.equal(urls[0].searchParams.get('nelat'), '47');
|
||||
assert.equal(urls[0].searchParams.get('per_page'), '200');
|
||||
assert.equal(urls[0].searchParams.get('quality_grade'), 'research');
|
||||
assert.equal(urls[0].searchParams.get('iconic_taxa'), 'Aves');
|
||||
await request(boxUrl(0, '&taxa=whales'));
|
||||
const whales = urls[1].searchParams;
|
||||
assert.equal(whales.get('taxon_id'), '152871');
|
||||
assert.equal(whales.get('d1'), '2026-10-28');
|
||||
assert.equal(whales.has('created_d1'), false);
|
||||
assert.equal(whales.has('photos'), false);
|
||||
assert.equal(whales.has('iconic_taxa'), false);
|
||||
await request('/observations?scope=global&quality=typo&taxa=typo&south=bad');
|
||||
const global = urls[2].searchParams;
|
||||
for (const field of [
|
||||
'swlat',
|
||||
'swlng',
|
||||
'nelat',
|
||||
'nelng',
|
||||
'quality_grade',
|
||||
'iconic_taxa',
|
||||
])
|
||||
assert.equal(global.has(field), false, field);
|
||||
assert.equal(global.get('per_page'), '200');
|
||||
assert.equal(global.get('d1'), '2026-11-02');
|
||||
assert.equal(global.has('created_d1'), false);
|
||||
assert.equal(global.get('order_by'), 'observed_on');
|
||||
assert.equal(global.get('order'), 'desc');
|
||||
await request('/observations?scope=global&taxa=whales');
|
||||
const globalWhales = urls[3].searchParams;
|
||||
assert.equal(globalWhales.get('d1'), '2026-10-28');
|
||||
assert.equal(globalWhales.has('created_d1'), false);
|
||||
assert.equal(globalWhales.get('order_by'), 'observed_on');
|
||||
assert.equal(globalWhales.get('order'), 'desc');
|
||||
});
|
||||
|
||||
test('fresh cache hits and global ten-minute TTL spend no upstream quota', async () => {
|
||||
let at = NOW;
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
now: () => at,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return Response.json(envelope());
|
||||
},
|
||||
});
|
||||
const first = await request();
|
||||
at += 299_999;
|
||||
assert.deepEqual((await request()).body, first.body);
|
||||
at += 1;
|
||||
await request();
|
||||
assert.equal(calls, 2);
|
||||
await request('/observations?scope=global');
|
||||
at += 599_999;
|
||||
await request('/observations?scope=global');
|
||||
assert.equal(calls, 3);
|
||||
at += 1;
|
||||
await request('/observations?scope=global');
|
||||
assert.equal(calls, 4);
|
||||
});
|
||||
|
||||
test('the same snapped view and window hour returns one cached random sample until expiry', async () => {
|
||||
let at = NOW;
|
||||
let draws = 0;
|
||||
const cache = new Map();
|
||||
const request = install({
|
||||
cache,
|
||||
now: () => at,
|
||||
fetchImpl: async (url) => {
|
||||
const query = new URL(url).searchParams;
|
||||
assert.equal(query.get('order_by'), 'random');
|
||||
assert.equal(query.get('per_page'), '200');
|
||||
return Response.json(envelope([observation(++draws)]));
|
||||
},
|
||||
});
|
||||
const first = await request();
|
||||
for (const elapsed of [1, 1000, 60_000, 299_999]) {
|
||||
at = NOW + elapsed;
|
||||
const next = await request();
|
||||
assert.deepEqual(next.body, first.body);
|
||||
}
|
||||
assert.equal(draws, 1);
|
||||
assert.match(cache.keys().next().value, /2026-11-02T18:00:00Z$/);
|
||||
at = NOW + 300_000;
|
||||
const refresh = await request();
|
||||
assert.equal(refresh.body.rows[0].stableId, 'inat:2');
|
||||
assert.equal(draws, 2);
|
||||
});
|
||||
|
||||
test('disconnected queued views are dropped while a remaining coalesced consumer keeps its request', async () => {
|
||||
const calls = [];
|
||||
const releases = [];
|
||||
const cache = new Map();
|
||||
const request = install({
|
||||
cache,
|
||||
fetchImpl: (url, { signal }) => {
|
||||
calls.push({ url: new URL(url), signal });
|
||||
return new Promise((resolve) => releases.push(resolve));
|
||||
},
|
||||
});
|
||||
const first = request(boxUrl(0));
|
||||
await until(() => releases.length === 1);
|
||||
const superseded = new AbortController();
|
||||
const oldView = request(boxUrl(1), { signal: superseded.signal });
|
||||
const oneConsumer = new AbortController();
|
||||
const one = request(boxUrl(2), { signal: oneConsumer.signal });
|
||||
const other = request(boxUrl(2));
|
||||
superseded.abort();
|
||||
oneConsumer.abort();
|
||||
await Promise.all([oldView, one]);
|
||||
// Rejoining the abandoned key creates a healthy generation; the old
|
||||
// coalescing promise must not later delete or cancel this replacement.
|
||||
const replacement = request(boxUrl(1));
|
||||
releases[0](Response.json(envelope()));
|
||||
await until(() => releases.length === 2);
|
||||
assert.equal(calls[1].url.searchParams.get('swlng'), '-122.1');
|
||||
assert.equal(calls[1].signal.aborted, false);
|
||||
releases[1](Response.json(envelope([observation(2)])));
|
||||
await until(() => releases.length === 3);
|
||||
assert.equal(calls[2].url.searchParams.get('swlng'), '-122.3');
|
||||
releases[2](Response.json(envelope([observation(3)])));
|
||||
const results = await Promise.all([first, other, replacement]);
|
||||
assert.ok(results.every((result) => result.status === 200));
|
||||
assert.equal(
|
||||
calls.length,
|
||||
3,
|
||||
'the abandoned queued generation never dispatched',
|
||||
);
|
||||
assert.equal(cache.size, 3);
|
||||
assert.equal(results[1].body.rows[0].stableId, 'inat:2');
|
||||
assert.equal(results[2].body.rows[0].stableId, 'inat:3');
|
||||
});
|
||||
|
||||
test('observation windows reject old sightings uploaded today and keep recent observations', async () => {
|
||||
for (const [taxa, recentTime, expectedD1] of [
|
||||
['all', '2026-11-04T17:00:00Z', '2026-11-02'],
|
||||
['whales', '2026-10-30T17:00:00Z', '2026-10-28'],
|
||||
]) {
|
||||
let upstreamQuery;
|
||||
const request = install({
|
||||
fetchImpl: async (url) => {
|
||||
upstreamQuery = new URL(url).searchParams;
|
||||
return Response.json(
|
||||
envelope([
|
||||
{
|
||||
...observation(80),
|
||||
time_observed_at: '2010-06-01T12:00:00Z',
|
||||
observed_on: '2010-06-01',
|
||||
},
|
||||
{
|
||||
...observation(81),
|
||||
time_observed_at: recentTime,
|
||||
observed_on: recentTime.slice(0, 10),
|
||||
},
|
||||
]),
|
||||
);
|
||||
},
|
||||
});
|
||||
const result = await request(boxUrl(0, `&taxa=${taxa}`));
|
||||
assert.equal(result.status, 200, taxa);
|
||||
assert.equal(upstreamQuery.get('d1'), expectedD1);
|
||||
assert.equal(upstreamQuery.has('created_d1'), false);
|
||||
assert.equal(upstreamQuery.get('order_by'), 'random');
|
||||
assert.deepEqual(
|
||||
result.body.rows.map((row) => row.stableId),
|
||||
['inat:81'],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('same-key requests single-flight and a seventeenth distinct in-flight key queues', async () => {
|
||||
const releases = [];
|
||||
const request = install({
|
||||
fetchImpl: () => new Promise((resolve) => releases.push(resolve)),
|
||||
});
|
||||
const a = request();
|
||||
const b = request();
|
||||
await until(() => releases.length === 1);
|
||||
releases.shift()(Response.json(envelope()));
|
||||
assert.deepEqual((await a).body, (await b).body);
|
||||
const pending = Array.from({ length: 16 }, (_, i) => request(boxUrl(i + 1)));
|
||||
await until(() => releases.length === 1);
|
||||
pending.push(request(boxUrl(18)));
|
||||
for (let i = 0; i < 17; i += 1) {
|
||||
await until(() => releases.length > i);
|
||||
releases[i](Response.json(envelope()));
|
||||
}
|
||||
assert.ok((await Promise.all(pending)).every(({ status }) => status === 200));
|
||||
});
|
||||
|
||||
test('a fresh global sample survives the hourly observation-window rollover without another fetch', async () => {
|
||||
let at = Date.parse('2026-11-04T18:59:00Z');
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
now: () => at,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return Response.json(envelope());
|
||||
},
|
||||
});
|
||||
const first = await request('/observations?scope=global');
|
||||
at += 60_000;
|
||||
const nextHour = await request('/observations?scope=global');
|
||||
assert.deepEqual(nextHour.body, first.body);
|
||||
assert.equal(nextHour.body.stale, undefined);
|
||||
assert.equal(calls, 1);
|
||||
at += 9 * 60_000;
|
||||
await request('/observations?scope=global');
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('pending global refresh coalesces across the hourly observation-window rollover with a busy queue', async () => {
|
||||
let at = Date.parse('2026-11-04T18:59:59Z');
|
||||
const releases = [];
|
||||
const cache = new Map();
|
||||
const request = install({
|
||||
cache,
|
||||
now: () => at,
|
||||
fetchImpl: (url) =>
|
||||
new Promise((resolve) =>
|
||||
releases.push({
|
||||
resolve,
|
||||
url: new URL(url),
|
||||
}),
|
||||
),
|
||||
});
|
||||
const first = request(
|
||||
'/observations?scope=global&quality=research&taxa=Aves',
|
||||
);
|
||||
const views = Array.from({ length: 15 }, (_, i) => request(boxUrl(i)));
|
||||
await until(() => releases.length === 1);
|
||||
assert.equal(releases[0].url.searchParams.get('d1'), '2026-11-02');
|
||||
at = Date.parse('2026-11-04T19:00:01Z');
|
||||
const nextHour = request(
|
||||
'/observations?scope=global&quality=research&taxa=Aves',
|
||||
);
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(
|
||||
releases.length,
|
||||
1,
|
||||
'the same global query joins the pending refresh',
|
||||
);
|
||||
const distinct = request(
|
||||
'/observations?scope=global&quality=research&taxa=Mammalia',
|
||||
);
|
||||
for (let i = 0; i < 17; i += 1) {
|
||||
await until(() => releases.length > i);
|
||||
releases[i].resolve(Response.json(envelope()));
|
||||
}
|
||||
const [original, joined] = await Promise.all([first, nextHour]);
|
||||
assert.equal(original.status, 200);
|
||||
assert.equal((await distinct).status, 200);
|
||||
assert.deepEqual(joined.body, original.body);
|
||||
assert.ok((await Promise.all(views)).every(({ status }) => status === 200));
|
||||
const globalEntries = [...cache.entries()].filter(
|
||||
([, entry]) => entry.value.scope === 'global',
|
||||
);
|
||||
assert.equal(globalEntries.length, 2);
|
||||
assert.match(globalEntries[0][0], /2026-11-02T18:00:00Z$/);
|
||||
assert.equal(
|
||||
(await request('/observations?scope=global&quality=research&taxa=Aves'))
|
||||
.status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
releases.length,
|
||||
17,
|
||||
'the accepted global answer keeps its ten-minute freshness',
|
||||
);
|
||||
});
|
||||
|
||||
test('view refreshes retain distinct hourly keys while a previous-hour request is pending', async () => {
|
||||
let at = Date.parse('2026-11-04T18:59:59Z');
|
||||
const releases = [];
|
||||
const request = install({
|
||||
now: () => at,
|
||||
fetchImpl: () => new Promise((resolve) => releases.push(resolve)),
|
||||
});
|
||||
const first = request();
|
||||
await until(() => releases.length === 1);
|
||||
at = Date.parse('2026-11-04T19:00:01Z');
|
||||
const nextHour = request();
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(
|
||||
releases.length,
|
||||
1,
|
||||
'the next view queues behind the current call',
|
||||
);
|
||||
releases[0](Response.json(envelope()));
|
||||
await until(() => releases.length === 2);
|
||||
releases[1](Response.json(envelope()));
|
||||
assert.ok(
|
||||
(await Promise.all([first, nextHour])).every(
|
||||
({ status }) => status === 200,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('upstream minute ceiling waits for request 61 across client addresses', async () => {
|
||||
let at = NOW;
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
now: () => at,
|
||||
queueOptions: {
|
||||
now: () => at,
|
||||
intervalMs: 0,
|
||||
wait: async (ms) => {
|
||||
at += ms;
|
||||
},
|
||||
},
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return Response.json(envelope());
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < 60; i += 1)
|
||||
assert.equal(
|
||||
(await request(boxUrl(i), { peer: `10.0.0.${i}` })).status,
|
||||
200,
|
||||
);
|
||||
const queued = await request(boxUrl(60), { peer: '10.0.1.1' });
|
||||
assert.equal(queued.status, 200);
|
||||
assert.equal(queued.headers['Retry-After'], undefined);
|
||||
assert.equal(at, NOW + 60_000);
|
||||
assert.equal(calls, 61);
|
||||
});
|
||||
|
||||
test('views refresh after many prior requests without fabricating an upstream limit', async () => {
|
||||
let at = NOW;
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
now: () => at,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return Response.json(envelope());
|
||||
},
|
||||
});
|
||||
const first = await request();
|
||||
at += 300_001;
|
||||
for (let i = 1; i <= 60; i += 1)
|
||||
await request(boxUrl(i), { peer: `10.0.0.${i}` });
|
||||
const stale = await request();
|
||||
assert.equal(stale.status, 200);
|
||||
assert.equal(stale.body.stale, undefined);
|
||||
assert.notEqual(stale.body.fetchedAt, first.body.fetchedAt);
|
||||
assert.equal(calls, 62);
|
||||
});
|
||||
|
||||
test('bounded samples preserve partial metadata, and well-formed empty views are valid', async () => {
|
||||
let at = NOW;
|
||||
let fail = false;
|
||||
const request = install({
|
||||
now: () => at,
|
||||
fetchImpl: async () =>
|
||||
fail
|
||||
? new Response('unavailable', { status: 503 })
|
||||
: Response.json(envelope([observation()], 500)),
|
||||
});
|
||||
const first = await request();
|
||||
assert.equal(first.body.partial, true);
|
||||
assert.equal(first.body.rawRowCount, 500);
|
||||
fail = true;
|
||||
at += 59 * 60_000;
|
||||
const stale = await request();
|
||||
assert.equal(
|
||||
stale.status,
|
||||
200,
|
||||
'hourly window changes still permit stale fallback',
|
||||
);
|
||||
assert.equal(stale.headers['X-Data-Stale'], 'true');
|
||||
assert.equal(stale.body.stale, true);
|
||||
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
|
||||
assert.equal(stale.body.partial, true);
|
||||
at += 2 * 60_000;
|
||||
const expired = await request();
|
||||
assert.equal(expired.status, 502);
|
||||
assert.equal(expired.body.error, 'inaturalist_unavailable');
|
||||
const empty = install({
|
||||
fetchImpl: async () => Response.json(envelope([], 0)),
|
||||
});
|
||||
const result = await empty();
|
||||
assert.equal(result.status, 200);
|
||||
assert.deepEqual(result.body.rows, []);
|
||||
});
|
||||
|
||||
test('bad envelopes, JSON and capped bodies fail closed without inventing a cooldown', async () => {
|
||||
for (const bad of [
|
||||
() => Response.json({ total_results: 1, results: {} }),
|
||||
() => Response.json({ total_results: 1, observations: [observation()] }),
|
||||
() => Response.json({ total_results: '1', results: [observation()] }),
|
||||
() => Response.json(envelope([], 1)),
|
||||
() => Response.json(envelope([{ ...observation(), id: null }], 1)),
|
||||
() => new Response('{malformed'),
|
||||
() =>
|
||||
new Response('{}', {
|
||||
headers: { 'Content-Length': String(8 * 1024 * 1024 + 1) },
|
||||
}),
|
||||
]) {
|
||||
let at = NOW;
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
now: () => at,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return calls === 1 ? bad() : Response.json(envelope());
|
||||
},
|
||||
});
|
||||
const first = await request();
|
||||
assert.equal(first.status, 502);
|
||||
assert.equal(first.body.error, 'inaturalist_unavailable');
|
||||
assert.equal(first.headers['Retry-After'], undefined);
|
||||
assert.equal((await request(boxUrl(1))).status, 200);
|
||||
assert.equal(calls, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('a wholly invalid iNat page cannot replace a previously valid snapshot', async () => {
|
||||
let at = NOW;
|
||||
let fail = false;
|
||||
const cache = new Map();
|
||||
const request = install({
|
||||
cache,
|
||||
now: () => at,
|
||||
fetchImpl: async () =>
|
||||
Response.json(
|
||||
fail
|
||||
? envelope([{ ...observation(), quality_grade: 'casual' }])
|
||||
: envelope(),
|
||||
),
|
||||
});
|
||||
const first = await request();
|
||||
const original = cache.values().next().value;
|
||||
fail = true;
|
||||
at += 300_001;
|
||||
const stale = await request();
|
||||
assert.equal(stale.status, 200);
|
||||
assert.equal(stale.body.stale, true);
|
||||
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
|
||||
assert.equal(cache.values().next().value, original);
|
||||
});
|
||||
|
||||
test('429 cooldown honors seconds, HTTP dates, and long upstream deadlines', async () => {
|
||||
assert.equal(inatRetryCooldownMs(null, NOW), 60_000);
|
||||
assert.equal(inatRetryCooldownMs('0', NOW), 0);
|
||||
assert.equal(inatRetryCooldownMs('2', NOW), 2000);
|
||||
assert.equal(inatRetryCooldownMs('999999', NOW), 999_999_000);
|
||||
assert.equal(inatRetryCooldownMs('7200', NOW), 7_200_000);
|
||||
assert.equal(
|
||||
inatRetryCooldownMs(new Date(NOW + 7_200_000).toUTCString(), NOW),
|
||||
7_200_000,
|
||||
);
|
||||
let at = NOW;
|
||||
const releases = [];
|
||||
const warnings = [];
|
||||
const request = install({
|
||||
now: () => at,
|
||||
warn: (message) => warnings.push(message),
|
||||
fetchImpl: () => new Promise((resolve) => releases.push(resolve)),
|
||||
});
|
||||
const first = request();
|
||||
const later = request(boxUrl(1));
|
||||
await until(() => releases.length === 1);
|
||||
releases[0](
|
||||
new Response('limit', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '999999' },
|
||||
}),
|
||||
);
|
||||
assert.equal((await first).headers['Retry-After'], '999999');
|
||||
assert.equal((await later).headers['Retry-After'], '999999');
|
||||
at += 1000;
|
||||
assert.equal((await request(boxUrl(1))).headers['Retry-After'], '999998');
|
||||
at += 5000;
|
||||
const cooling = await request(boxUrl(2));
|
||||
assert.equal(cooling.status, 429);
|
||||
assert.equal(cooling.headers['Retry-After'], '999993');
|
||||
assert.equal(
|
||||
releases.length,
|
||||
1,
|
||||
'queued calls do not dispatch during cooldown',
|
||||
);
|
||||
assert.equal(
|
||||
warnings.filter((message) => message.includes('capped')).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
for (const [upstreamStatus, expectedStatus, code, retryAfterS] of [
|
||||
[429, 429, 'inaturalist_rate_limited', 120],
|
||||
[503, 502, 'inaturalist_unavailable', 120],
|
||||
]) {
|
||||
test(`concurrent reservations honor an immediate upstream ${upstreamStatus} before dispatch`, async () => {
|
||||
let calls = 0;
|
||||
const warnings = [];
|
||||
const client = createInatClient({
|
||||
now: () => 0,
|
||||
cacheDir: null,
|
||||
queueOptions: { intervalMs: 0 },
|
||||
warn: (message) => warnings.push(message),
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return new Response('synthetic failure', {
|
||||
status: upstreamStatus,
|
||||
headers: { 'Retry-After': '120' },
|
||||
});
|
||||
},
|
||||
});
|
||||
const params = {
|
||||
scope: 'global',
|
||||
quality: 'all',
|
||||
taxa: 'all',
|
||||
observedD1: '1969-12-30',
|
||||
};
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: 3 }, () => client.getObservations(params)),
|
||||
);
|
||||
assert.equal(
|
||||
calls,
|
||||
1,
|
||||
'pending reservations must not dispatch during cooldown',
|
||||
);
|
||||
for (const result of results) {
|
||||
assert.equal(result.status, 'rejected');
|
||||
assert.equal(result.reason.status, expectedStatus);
|
||||
assert.equal(result.reason.code, code);
|
||||
assert.equal(result.reason.retryAfterS, retryAfterS);
|
||||
}
|
||||
assert.equal(
|
||||
warnings.length,
|
||||
1,
|
||||
'only the dispatched refresh logs a failure',
|
||||
);
|
||||
await assert.rejects(client.getObservations(params), {
|
||||
status: expectedStatus,
|
||||
code,
|
||||
retryAfterS,
|
||||
});
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test('budget is read lazily, persisted as counters only, enforced across restart and UTC rollover', async (t) => {
|
||||
const parent = await mkdtemp(path.join(os.tmpdir(), 'gev-inat-budget-'));
|
||||
t.after(() => rm(parent, { recursive: true, force: true }));
|
||||
const cacheDir = path.join(parent, 'not-created');
|
||||
const before = process.env.INATURALIST_DAILY_BUDGET;
|
||||
process.env.INATURALIST_DAILY_BUDGET = '2';
|
||||
t.after(() =>
|
||||
before === undefined
|
||||
? delete process.env.INATURALIST_DAILY_BUDGET
|
||||
: (process.env.INATURALIST_DAILY_BUDGET = before),
|
||||
);
|
||||
let at = NOW;
|
||||
let calls = 0;
|
||||
const options = {
|
||||
now: () => at,
|
||||
cacheDir,
|
||||
warn() {},
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return Response.json(envelope());
|
||||
},
|
||||
};
|
||||
const client = createInatClient(options);
|
||||
assert.deepEqual(
|
||||
await readdir(parent),
|
||||
[],
|
||||
'construction performs no directory write',
|
||||
);
|
||||
const params = {
|
||||
scope: 'global',
|
||||
quality: 'all',
|
||||
taxa: 'all',
|
||||
observedD1: '2026-11-02',
|
||||
};
|
||||
await Promise.all([
|
||||
client.getObservations(params),
|
||||
client.getObservations(params),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
JSON.parse(await readFile(path.join(cacheDir, 'budget.json'), 'utf8')),
|
||||
{ date: '2026-11-04', count: 2 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await readdir(cacheDir),
|
||||
['budget.json'],
|
||||
'atomic temporary files are retired',
|
||||
);
|
||||
const restarted = createInatClient(options);
|
||||
await assert.rejects(restarted.getObservations(params), {
|
||||
status: 503,
|
||||
code: 'inaturalist_budget_exhausted',
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
at = Date.parse('2026-11-05T00:00:00Z');
|
||||
await restarted.getObservations(params);
|
||||
assert.deepEqual(restarted.budgetState(), {
|
||||
date: '2026-11-05',
|
||||
count: 1,
|
||||
budget: 2,
|
||||
});
|
||||
assert.deepEqual(
|
||||
JSON.parse(await readFile(path.join(cacheDir, 'budget.json'), 'utf8')),
|
||||
{ date: '2026-11-05', count: 1 },
|
||||
);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
test('memory cache is an LRU bounded to 400 projected entries', async () => {
|
||||
let at = NOW;
|
||||
const cache = new Map();
|
||||
const request = install({
|
||||
cache,
|
||||
now: () => at,
|
||||
fetchImpl: async () => Response.json(envelope()),
|
||||
});
|
||||
for (let i = 0; i < 400; i += 1) {
|
||||
if (i && i % 20 === 0) at += 61_000;
|
||||
await request(boxUrl(i), { peer: String(i) });
|
||||
}
|
||||
assert.equal(cache.size, 400);
|
||||
const firstKey = cache.keys().next().value;
|
||||
const secondKey = [...cache.keys()][1];
|
||||
await request(boxUrl(0));
|
||||
await request(boxUrl(400));
|
||||
assert.equal(cache.size, 400);
|
||||
assert.equal(cache.has(firstKey), true);
|
||||
assert.equal(cache.has(secondKey), false);
|
||||
});
|
||||
|
||||
test('aborted client calls spend no budget and never fetch', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const client = createInatClient({
|
||||
now: () => NOW,
|
||||
cacheDir: null,
|
||||
queueOptions: { intervalMs: 0 },
|
||||
fetchImpl: () => assert.fail('aborted call fetched'),
|
||||
});
|
||||
await assert.rejects(
|
||||
client.getObservations({}, { signal: controller.signal }),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
assert.equal(client.budgetState().count, 0);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { inaturalistProxy } from './observations.js';
|
||||
|
||||
export const NOW = Date.parse('2026-11-04T18:05:00Z');
|
||||
|
||||
/** Mount the proxy over synthetic HTTP objects, without opening a socket. */
|
||||
export function installInatProxy(
|
||||
defaultUrl,
|
||||
options = {},
|
||||
hook = 'configureServer',
|
||||
) {
|
||||
let handler;
|
||||
const plugin = inaturalistProxy({
|
||||
now: () => NOW,
|
||||
cacheDir: null,
|
||||
queueOptions: { intervalMs: 0, windowMs: 0 },
|
||||
warn() {},
|
||||
...options,
|
||||
});
|
||||
assert.equal(plugin.name, 'inaturalist');
|
||||
assert.equal(typeof plugin.configureServer, 'function');
|
||||
assert.equal(typeof plugin.configurePreviewServer, 'function');
|
||||
plugin[hook]({
|
||||
middlewares: {
|
||||
use(mount, callback) {
|
||||
assert.equal(mount, '/api/inaturalist');
|
||||
handler = callback;
|
||||
},
|
||||
},
|
||||
});
|
||||
return async (
|
||||
url = defaultUrl,
|
||||
{ method = 'GET', peer = 'local', headers = {}, signal } = {},
|
||||
) => {
|
||||
const res = Object.assign(new EventEmitter(), {
|
||||
headers: {},
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
Object.assign(this.headers, headers);
|
||||
},
|
||||
setHeader(key, value) {
|
||||
this.headers[key] = value;
|
||||
},
|
||||
end(value) {
|
||||
this.status ??= this.statusCode;
|
||||
this.body = JSON.parse(value);
|
||||
},
|
||||
});
|
||||
const abort = () => {
|
||||
res.destroyed = true;
|
||||
res.emit('close');
|
||||
};
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
try {
|
||||
await handler(
|
||||
Object.assign(new EventEmitter(), {
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
socket: { remoteAddress: peer },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
// A four-request burst keeps a cold view responsive; thereafter every provider
|
||||
// call shares the same one-per-second token supply. The rolling minute ceiling
|
||||
// queues the few requests a token bucket alone could allow above 60/min.
|
||||
const INAT_INTERVAL_MS = 1000;
|
||||
const INAT_BURST = 4;
|
||||
const INAT_WINDOW_MS = 60_000;
|
||||
const INAT_MAX_PER_WINDOW = 60;
|
||||
const INAT_MAX_IN_FLIGHT = 4;
|
||||
|
||||
/** Observation queries outrank tiles waiting for a token. Aborted consumers
|
||||
* leave the queue before an attempt or daily-budget reservation is spent. */
|
||||
export function createInatQueue({
|
||||
now = () => performance.now(),
|
||||
wait = (ms) => delay(ms),
|
||||
intervalMs = INAT_INTERVAL_MS,
|
||||
burst = INAT_BURST,
|
||||
windowMs = INAT_WINDOW_MS,
|
||||
maxInFlight = INAT_MAX_IN_FLIGHT,
|
||||
} = {}) {
|
||||
const pending = [];
|
||||
const dispatchedAt = [];
|
||||
let tokens = burst;
|
||||
let refilledAt = null;
|
||||
let running = false;
|
||||
let inFlight = 0;
|
||||
let slotFreed = null;
|
||||
|
||||
function refill() {
|
||||
const at = now();
|
||||
if (refilledAt !== null && intervalMs > 0)
|
||||
tokens = Math.min(
|
||||
burst,
|
||||
tokens + Math.max(0, at - refilledAt) / intervalMs,
|
||||
);
|
||||
if (intervalMs <= 0) tokens = burst;
|
||||
refilledAt = at;
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
// A microtask also lets same-turn observation queries take priority.
|
||||
void Promise.resolve().then(drain);
|
||||
}
|
||||
|
||||
async function drain() {
|
||||
try {
|
||||
while (pending.length) {
|
||||
if (inFlight >= maxInFlight) {
|
||||
await new Promise((resolve) => (slotFreed = resolve));
|
||||
continue;
|
||||
}
|
||||
refill();
|
||||
const at = now();
|
||||
while (dispatchedAt.length && dispatchedAt[0] <= at - windowMs)
|
||||
dispatchedAt.shift();
|
||||
const tokenWait = tokens < 1 ? (1 - tokens) * intervalMs : 0;
|
||||
const windowWait =
|
||||
windowMs > 0 && dispatchedAt.length >= INAT_MAX_PER_WINDOW
|
||||
? dispatchedAt[0] + windowMs - at
|
||||
: 0;
|
||||
if (tokenWait > 0 || windowWait > 0) {
|
||||
await wait(Math.ceil(Math.max(tokenWait, windowWait)));
|
||||
continue;
|
||||
}
|
||||
// Stable sorting preserves FIFO order within each priority.
|
||||
pending.sort((a, b) => a.priority - b.priority);
|
||||
const job = pending.shift();
|
||||
job.signal?.removeEventListener('abort', job.abort);
|
||||
if (job.signal?.aborted) {
|
||||
job.reject(job.signal.reason);
|
||||
continue;
|
||||
}
|
||||
tokens -= 1;
|
||||
if (windowMs > 0) dispatchedAt.push(at);
|
||||
inFlight += 1;
|
||||
// A rejected task releases its slot without stopping the queue.
|
||||
void new Promise((resolve) => resolve(job.task()))
|
||||
.then(job.resolve, job.reject)
|
||||
.finally(() => {
|
||||
inFlight -= 1;
|
||||
const wake = slotFreed;
|
||||
slotFreed = null;
|
||||
wake?.();
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
return function enqueue(task, { signal, priority = 1 } = {}) {
|
||||
if (signal?.aborted) return Promise.reject(signal.reason);
|
||||
return new Promise((resolve, reject) => {
|
||||
const job = { task, signal, priority, resolve, reject };
|
||||
job.abort = () => {
|
||||
const index = pending.indexOf(job);
|
||||
if (index >= 0) pending.splice(index, 1);
|
||||
signal.removeEventListener('abort', job.abort);
|
||||
reject(signal.reason);
|
||||
};
|
||||
signal?.addEventListener('abort', job.abort, { once: true });
|
||||
pending.push(job);
|
||||
schedule();
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createInatQueue } from './queue.js';
|
||||
|
||||
async function until(check) {
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
if (check()) return;
|
||||
await new Promise(setImmediate);
|
||||
}
|
||||
assert.fail('queue did not reach its expected state');
|
||||
}
|
||||
|
||||
test('shared governor paces a four-call burst within the rolling minute ceiling', async () => {
|
||||
let at = 0;
|
||||
const waits = [];
|
||||
const queue = createInatQueue({
|
||||
now: () => at,
|
||||
wait: async (ms) => {
|
||||
waits.push(ms);
|
||||
at += ms;
|
||||
},
|
||||
});
|
||||
const dispatched = [];
|
||||
await Promise.all(
|
||||
Array.from({ length: 65 }, () => queue(() => dispatched.push(at))),
|
||||
);
|
||||
assert.equal(dispatched.length, 65);
|
||||
assert.deepEqual(dispatched.slice(0, 7), [0, 0, 0, 0, 1000, 2000, 3000]);
|
||||
assert.deepEqual(waits.slice(0, 3), [1000, 1000, 1000]);
|
||||
for (const time of dispatched)
|
||||
assert.ok(
|
||||
dispatched.filter((other) => other > time - 60_000 && other <= time)
|
||||
.length <= 60,
|
||||
);
|
||||
assert.equal(dispatched[60], 60_000);
|
||||
});
|
||||
|
||||
test('an observation arriving during the pacing wait overtakes queued tiles', async () => {
|
||||
let at = 0;
|
||||
const waits = [];
|
||||
const queue = createInatQueue({
|
||||
burst: 1,
|
||||
now: () => at,
|
||||
wait: (ms) => new Promise((resolve) => waits.push({ ms, resolve })),
|
||||
});
|
||||
const dispatched = [];
|
||||
await queue(() => dispatched.push('first tile'));
|
||||
const tile = queue(() => dispatched.push('waiting tile'));
|
||||
await until(() => waits.length === 1);
|
||||
const observation = queue(() => dispatched.push('observation'), {
|
||||
priority: 0,
|
||||
});
|
||||
at += waits[0].ms;
|
||||
waits[0].resolve();
|
||||
await observation;
|
||||
assert.deepEqual(dispatched, ['first tile', 'observation']);
|
||||
await until(() => waits.length === 2);
|
||||
at += waits[1].ms;
|
||||
waits[1].resolve();
|
||||
await tile;
|
||||
assert.deepEqual(dispatched, ['first tile', 'observation', 'waiting tile']);
|
||||
});
|
||||
|
||||
test('superseded tasks leave before dispatch, and a rejection cannot poison the tail', async () => {
|
||||
const queue = createInatQueue({ intervalMs: 0 });
|
||||
const dispatched = [];
|
||||
let release;
|
||||
const first = queue(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
await until(() => release);
|
||||
const controller = new AbortController();
|
||||
const superseded = queue(() => assert.fail('superseded tile dispatched'), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const dropped = assert.rejects(superseded, { name: 'AbortError' });
|
||||
controller.abort();
|
||||
await dropped;
|
||||
const failed = queue(() => {
|
||||
dispatched.push('failed');
|
||||
throw new Error('synthetic');
|
||||
});
|
||||
const failure = assert.rejects(failed, /synthetic/);
|
||||
const succeeding = queue(() => dispatched.push('recovered'));
|
||||
release();
|
||||
await Promise.all([first, failure, succeeding]);
|
||||
assert.deepEqual(dispatched, ['failed', 'recovered']);
|
||||
});
|
||||
|
||||
test('a slow call does not hold later calls; at most four run at once', async () => {
|
||||
const queue = createInatQueue({ intervalMs: 0, windowMs: 0 });
|
||||
const releases = [];
|
||||
const started = [];
|
||||
const slow = (id) => () =>
|
||||
new Promise((resolve) => {
|
||||
started.push(id);
|
||||
releases.push(() => resolve(id));
|
||||
});
|
||||
const jobs = [1, 2, 3, 4, 5].map((id) => queue(slow(id)));
|
||||
await until(() => started.length === 4);
|
||||
assert.deepEqual(started, [1, 2, 3, 4], 'four start without waiting');
|
||||
releases[1]();
|
||||
assert.equal(await jobs[1], 2, 'a later call finishes before the first');
|
||||
await until(() => started.length === 5);
|
||||
assert.deepEqual(started, [1, 2, 3, 4, 5], 'a freed slot starts the next');
|
||||
for (const release of releases) release();
|
||||
assert.deepEqual(await Promise.all(jobs), [1, 2, 3, 4, 5]);
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createInatClient } from './client.js';
|
||||
import { installInatProxy, NOW } from './proxy.fixture.mjs';
|
||||
import { syntheticGrid } from './grid.fixture.mjs';
|
||||
import { INAT_GRID_DECODE_MEASURE } from './grid.js';
|
||||
|
||||
const gridUrl = '/grid?z=2&x=1&y=1&taxa=Aves';
|
||||
const params = {
|
||||
scope: 'global',
|
||||
taxa: 'all',
|
||||
observedD1: '2026-11-02',
|
||||
};
|
||||
const tick = () => new Promise(setImmediate);
|
||||
|
||||
const install = (options, hook) => installInatProxy(gridUrl, options, hook);
|
||||
|
||||
test('tile cache lasts fifteen minutes, then returns stale before revalidation finishes', async () => {
|
||||
let at = NOW;
|
||||
let calls = 0;
|
||||
let release;
|
||||
const request = install({
|
||||
now: () => at,
|
||||
fetchImpl: () => {
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? Response.json(syntheticGrid())
|
||||
: new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
},
|
||||
});
|
||||
const first = await request();
|
||||
at += 899_999;
|
||||
const freshPromise = request();
|
||||
await tick();
|
||||
assert.equal(calls, 1, 'a fresh tile never spends another upstream attempt');
|
||||
assert.deepEqual((await freshPromise).body, first.body);
|
||||
at += 1;
|
||||
const stalePromise = request();
|
||||
let resolved = false;
|
||||
void stalePromise.then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
await tick();
|
||||
try {
|
||||
assert.equal(
|
||||
resolved,
|
||||
true,
|
||||
'stale cells publish while refresh is pending',
|
||||
);
|
||||
const stale = await stalePromise;
|
||||
assert.equal(stale.body.stale, true);
|
||||
assert.equal(stale.body.fetchedAt, NOW);
|
||||
assert.deepEqual(stale.body.cells, first.body.cells);
|
||||
await request();
|
||||
assert.equal(calls, 2, 'all stale consumers share one background refresh');
|
||||
const refreshPromise = request(`${gridUrl}&refresh=wait`);
|
||||
let refreshed = false;
|
||||
void refreshPromise.then(() => {
|
||||
refreshed = true;
|
||||
});
|
||||
await tick();
|
||||
assert.equal(
|
||||
refreshed,
|
||||
false,
|
||||
'a background consumer can await the same refresh',
|
||||
);
|
||||
release(Response.json(syntheticGrid()));
|
||||
const fresh = await refreshPromise;
|
||||
assert.equal(fresh.body.stale, undefined);
|
||||
assert.equal(fresh.body.fetchedAt, at);
|
||||
assert.equal(
|
||||
calls,
|
||||
2,
|
||||
'waiting for fresh cells never duplicates upstream work',
|
||||
);
|
||||
release = null;
|
||||
} finally {
|
||||
release?.(Response.json(syntheticGrid()));
|
||||
}
|
||||
});
|
||||
|
||||
test('more than sixteen queued views and thirty local requests never report local rate limiting', async () => {
|
||||
let release;
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1)
|
||||
await new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return Response.json(syntheticGrid());
|
||||
},
|
||||
});
|
||||
const pending = Array.from({ length: 40 }, (_, x) =>
|
||||
request(`/grid?z=6&x=${x}&y=1`),
|
||||
);
|
||||
await tick();
|
||||
release();
|
||||
const results = await Promise.all(pending);
|
||||
assert.ok(results.every((result) => result.status === 200));
|
||||
assert.equal(calls, 40);
|
||||
});
|
||||
|
||||
test('only real upstream 429 or 5xx failures start cooldown', async () => {
|
||||
for (const bad of [
|
||||
() => Promise.reject(new Error('network')),
|
||||
() => Response.json({ malformed: true }),
|
||||
() => new Response('', { status: 404 }),
|
||||
]) {
|
||||
let calls = 0;
|
||||
const client = createInatClient({
|
||||
now: () => NOW,
|
||||
cacheDir: null,
|
||||
queueOptions: { intervalMs: 0, windowMs: 0 },
|
||||
warn() {},
|
||||
fetchImpl: () => {
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? bad()
|
||||
: Response.json({ results: [], total_results: 0 });
|
||||
},
|
||||
});
|
||||
await assert.rejects(client.getObservations(params), { status: 502 });
|
||||
await client.getObservations(params);
|
||||
assert.equal(
|
||||
calls,
|
||||
2,
|
||||
'a non-cooldown failure must not hold the next attempt',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('actual upstream cooldown is marked separately from a local budget stop', async () => {
|
||||
for (const upstream of [429, 503]) {
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
fetchImpl: () => {
|
||||
calls += 1;
|
||||
return new Response('', {
|
||||
status: upstream,
|
||||
headers: { 'Retry-After': '120' },
|
||||
});
|
||||
},
|
||||
});
|
||||
const first = await request();
|
||||
assert.equal(first.headers['X-Upstream-Status'], String(upstream));
|
||||
assert.equal(first.headers['Retry-After'], '120');
|
||||
await request();
|
||||
assert.equal(calls, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('upstream Retry-After zero survives the HTTP response and permits an immediate next request', async () => {
|
||||
for (const upstream of [429, 503]) {
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
fetchImpl: () => {
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? new Response('', {
|
||||
status: upstream,
|
||||
headers: { 'Retry-After': '0' },
|
||||
})
|
||||
: Response.json(syntheticGrid());
|
||||
},
|
||||
});
|
||||
const first = await request();
|
||||
assert.equal(first.headers['X-Upstream-Status'], String(upstream));
|
||||
assert.equal(first.headers['Retry-After'], '0');
|
||||
assert.equal((await request()).status, 200);
|
||||
assert.equal(calls, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('a local daily budget stop has a distinct code and no upstream cooldown marker', async (t) => {
|
||||
const before = process.env.INATURALIST_DAILY_BUDGET;
|
||||
process.env.INATURALIST_DAILY_BUDGET = '1';
|
||||
t.after(() => {
|
||||
if (before === undefined) delete process.env.INATURALIST_DAILY_BUDGET;
|
||||
else process.env.INATURALIST_DAILY_BUDGET = before;
|
||||
});
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
fetchImpl: () => {
|
||||
calls += 1;
|
||||
return Response.json(syntheticGrid());
|
||||
},
|
||||
});
|
||||
assert.equal((await request()).status, 200);
|
||||
const stopped = await request('/grid?z=2&x=0&y=1');
|
||||
assert.equal(stopped.status, 503);
|
||||
assert.equal(stopped.body.error, 'inaturalist_budget_exhausted');
|
||||
assert.equal(stopped.headers['X-Upstream-Status'], undefined);
|
||||
assert.equal((await request()).status, 200, 'cached data remains available');
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('cached tiles avoid UTFGrid decode and decode timing entries stay bounded', async () => {
|
||||
const request = install({
|
||||
fetchImpl: () => Response.json(syntheticGrid()),
|
||||
});
|
||||
await request();
|
||||
const sample = performance.getEntriesByName(INAT_GRID_DECODE_MEASURE)[0];
|
||||
assert.ok(sample.duration >= 0);
|
||||
for (let i = 0; i < 10; i += 1) await request();
|
||||
assert.equal(
|
||||
performance.getEntriesByName(INAT_GRID_DECODE_MEASURE)[0],
|
||||
sample,
|
||||
);
|
||||
for (let x = 0; x < 4; x += 1) await request(`/grid?z=3&x=${x}&y=1`);
|
||||
assert.equal(
|
||||
performance.getEntriesByName(INAT_GRID_DECODE_MEASURE).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
@@ -29,6 +29,8 @@ import { firePerimetersProxy } from './firePerimeters.js';
|
||||
import { gnssIntegrityProxy } from './gnssIntegrity.js';
|
||||
import { ghostMapsProxy } from './ghostMaps.js';
|
||||
import { droneAirspaceProxy } from './droneAirspace.js';
|
||||
import { inaturalistProxy } from './inaturalist/observations.js';
|
||||
import { acartiaProxy } from './acartia.js';
|
||||
import { cycloneProxy } from './cyclones.js';
|
||||
import { windProxy } from './wind.js';
|
||||
|
||||
@@ -81,6 +83,8 @@ function localProviderPlugins({
|
||||
gnssIntegrityProxy(),
|
||||
ghostMapsProxy(),
|
||||
droneAirspaceProxy(),
|
||||
inaturalistProxy(),
|
||||
acartiaProxy(),
|
||||
keySetupEndpoint(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
|
||||
satellitesLayer: 'satellites',
|
||||
cctvLayer: 'cctv',
|
||||
streetLevelLayer: 'street-level',
|
||||
wildlifeSightingsLayer: 'wildlife-sightings',
|
||||
radioLayer: 'radio',
|
||||
bikeshareLayer: 'bikeshare',
|
||||
transitLayer: 'transit',
|
||||
|
||||
@@ -139,6 +139,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
'rocket-launches',
|
||||
'street-level',
|
||||
'local-adsb',
|
||||
'wildlife-sightings',
|
||||
];
|
||||
const layers = ids.map((id) => ({ id }));
|
||||
const catalog = createLayerCatalog(layers, metadata(layers));
|
||||
@@ -147,6 +148,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
assert.equal(services.transitLayer, layers[7]);
|
||||
assert.equal(services.aisLiveVesselsLayer, layers[8]);
|
||||
assert.equal(services.localAdsbLayer, layers[13]);
|
||||
assert.equal(services.wildlifeSightingsLayer, layers[14]);
|
||||
assert.equal(new Set(Object.values(services)).size, layers.length);
|
||||
assert.throws(
|
||||
() => catalogControlServices(createLayerCatalog([], [])),
|
||||
|
||||
@@ -30,6 +30,7 @@ import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
||||
import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
||||
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
|
||||
import { createApplicationDroneAirspace } from './layers/droneAirspace.js';
|
||||
import { createApplicationWildlifeSightings } from './layers/wildlifeSightings.js';
|
||||
import { createApplicationCables } from './layers/submarineCables.js';
|
||||
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
|
||||
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
||||
@@ -137,6 +138,10 @@ export function createApplicationCatalog({
|
||||
createApplicationDroneAirspace({
|
||||
source: sources['drone-airspace'],
|
||||
}),
|
||||
createApplicationWildlifeSightings({
|
||||
source: sources['wildlife-sightings'],
|
||||
groundFloor: surface.groundFloor,
|
||||
}),
|
||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||
createApplicationStreetLevel({
|
||||
surface,
|
||||
|
||||
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
signal: b.signal,
|
||||
surface: fixtureSurface(b.signal),
|
||||
});
|
||||
assert.equal(first.layers.length, 37);
|
||||
assert.equal(first.layers.length, 38);
|
||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'osm-places'),
|
||||
@@ -161,7 +161,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
};
|
||||
const catalog = createApplicationCatalog(options);
|
||||
assert.equal(catalog.layers.length, 37);
|
||||
assert.equal(catalog.layers.length, 38);
|
||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||
assert.equal(
|
||||
catalogControlServices(catalog).streetLevelLayer,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createWildlifeSightingsLayer } from '../../layers/wildlifeSightings/index.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
import * as focus from '../../worldFocus.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/** Bind the wildlife layer to the application's scene and selection owners. */
|
||||
export function createApplicationWildlifeSightings(options) {
|
||||
return createWildlifeSightingsLayer({
|
||||
overlayHost: {
|
||||
setEntries: overlays.setOverlayEntries,
|
||||
setVisible: overlays.setOverlaySourceVisible,
|
||||
clearSource: overlays.clearOverlaySource,
|
||||
hitTest: overlays.hitTestWorldOverlay,
|
||||
},
|
||||
screenSpaceEventHandlerFactory: (viewer) =>
|
||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
||||
picking,
|
||||
pointer: { isPointerFree },
|
||||
sprites,
|
||||
render,
|
||||
context,
|
||||
focus,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -50,6 +50,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
||||
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
|
||||
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
|
||||
'drone-airspace': contract(['drone-airspace'], ['getSnapshot']),
|
||||
'wildlife-sightings': contract(['wildlife-sightings'], ['getSnapshot']),
|
||||
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
||||
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
|
||||
|
||||
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
assert.equal(actual.layers.length, 37);
|
||||
assert.equal(actual.layers.length, 38);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
|
||||
@@ -34,6 +34,14 @@ export const OPENMAPTILES_CREDIT = {
|
||||
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
|
||||
};
|
||||
|
||||
/** Conditional Salish Sea coverage; registered when its sightings first render. */
|
||||
export const ACARTIA_CREDIT = Object.freeze({
|
||||
key: 'acartia',
|
||||
html:
|
||||
'Wildlife sightings: <a href="https://acartia.io" target="_blank" rel="noopener">Acartia</a> (CC BY 4.0; each provider credited per sighting) · ' +
|
||||
'<a href="https://www.bewhalewise.org" target="_blank" rel="noopener">Be Whale Wise</a>',
|
||||
});
|
||||
|
||||
const osmDisplays = new WeakMap();
|
||||
|
||||
function updateOsmDisplay(viewer, state) {
|
||||
@@ -188,6 +196,10 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://adds-faa.opendata.arcgis.com/" target="_blank" rel="noopener">FAA Special Use Airspace</a>' +
|
||||
' (prohibited and restricted zones; informational only)',
|
||||
},
|
||||
{
|
||||
key: 'inaturalist',
|
||||
html: 'Wildlife sightings: <a href="https://www.inaturalist.org" target="_blank" rel="noopener">iNaturalist</a> contributors (licences per record; photos credited on each card)',
|
||||
},
|
||||
OSM_CREDIT,
|
||||
OPENMAPTILES_CREDIT,
|
||||
{
|
||||
|
||||
@@ -312,6 +312,23 @@ const OPTION_GROUPS = Object.freeze({
|
||||
high: 'h',
|
||||
}),
|
||||
]),
|
||||
'wildlife-sightings': Object.freeze([
|
||||
booleanOption('confirmed', 'c', false),
|
||||
enumOption(
|
||||
'taxa',
|
||||
't',
|
||||
'all',
|
||||
['all', 'Aves', 'Mammalia', 'whales', 'Insecta', 'Plantae'],
|
||||
{
|
||||
all: 'a',
|
||||
Aves: 'b',
|
||||
Mammalia: 'm',
|
||||
whales: 'w',
|
||||
Insecta: 'i',
|
||||
Plantae: 'p',
|
||||
},
|
||||
),
|
||||
]),
|
||||
traffic: Object.freeze([
|
||||
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
|
||||
tomtom: 't',
|
||||
@@ -733,6 +750,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'weather-satellite',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'wildlife-sightings',
|
||||
token: '9',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'wildlife-sightings',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'wind',
|
||||
token: 'k',
|
||||
|
||||
@@ -203,8 +203,8 @@ function encode(state) {
|
||||
|
||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||
assert.equal(validateLayerStateRegistry(), true);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 35);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 35);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 36);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 36);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
||||
@@ -243,7 +243,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
||||
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
||||
}
|
||||
assert.equal(nextLayerStateToken(), '9');
|
||||
assert.equal(nextLayerStateToken(), '00');
|
||||
assert.equal(
|
||||
nextLayerStateToken({
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
@@ -306,8 +306,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '9',
|
||||
next: '00',
|
||||
future: '00',
|
||||
next: '01',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -315,12 +315,13 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
() =>
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
future: '01',
|
||||
}),
|
||||
/next free token 9/,
|
||||
/next free token 00/,
|
||||
);
|
||||
const beforeLastDigit = { ...digitsExhausted };
|
||||
delete beforeLastDigit['prior-9'];
|
||||
delete beforeLastDigit['wildlife-sightings'];
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(beforeLastDigit, {
|
||||
...beforeLastDigit,
|
||||
@@ -2695,6 +2696,32 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
|
||||
);
|
||||
});
|
||||
|
||||
test('wildlife confirmed and every taxa preset round-trip through the full share codec', () => {
|
||||
assert.equal(
|
||||
LAYER_STATE_REGISTRY.find(({ id }) => id === 'wildlife-sightings').token,
|
||||
'9',
|
||||
);
|
||||
for (const taxa of [
|
||||
'all',
|
||||
'Aves',
|
||||
'Mammalia',
|
||||
'whales',
|
||||
'Insecta',
|
||||
'Plantae',
|
||||
]) {
|
||||
const state = normalizeLayerState({
|
||||
enabledLayerIds: ['wildlife-sightings'],
|
||||
options: { 'wildlife-sightings': { confirmed: true, taxa } },
|
||||
});
|
||||
const encoded = encode(state);
|
||||
assert.doesNotMatch(encoded, /undefined/);
|
||||
assert.deepEqual(
|
||||
decodeLayerStateParams(new URLSearchParams(encoded)),
|
||||
state,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('drone restrictions uses digit 8 without colliding with fire perimeters', () => {
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=8.2'));
|
||||
assert.deepEqual(decoded.enabledLayerIds, [
|
||||
|
||||
@@ -97,6 +97,10 @@ function withPublishedBase(
|
||||
}
|
||||
}
|
||||
|
||||
const preWildlifeRows = reservationRows.filter(
|
||||
([id]) => id !== 'wildlife-sightings',
|
||||
);
|
||||
|
||||
function writeCodecFixture(cwd, rows, entries) {
|
||||
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
|
||||
mkdirSync(path.dirname(providerCodec), { recursive: true });
|
||||
@@ -104,7 +108,12 @@ function writeCodecFixture(cwd, rows, entries) {
|
||||
providerCodec,
|
||||
readFileSync(new URL('./idSwitches.js', import.meta.url)),
|
||||
);
|
||||
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
|
||||
let source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
|
||||
if (!rows.some(([id]) => id === 'wildlife-sightings'))
|
||||
source = source.replace(
|
||||
/ Object\.freeze\(\{\s*id: 'wildlife-sightings',[\s\S]*?\}\),\n/,
|
||||
'',
|
||||
);
|
||||
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
|
||||
assert.equal(source.split(registryEnd).length, 2);
|
||||
const additions = entries
|
||||
@@ -128,7 +137,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
{ ...parseLayerStateTokenReservations(reservationRows) },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 35);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 36);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{
|
||||
@@ -140,6 +149,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
'sea-marks': '6',
|
||||
'ski-pistes': '7',
|
||||
'drone-airspace': '8',
|
||||
'wildlife-sightings': '9',
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
@@ -165,9 +175,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
assert.equal(
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '9']])
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '00']])
|
||||
.future,
|
||||
'9',
|
||||
'00',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -185,7 +195,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
|
||||
test('published retired digits and competing PRs advance the merge-time allocation', () => {
|
||||
withPublishedBase(
|
||||
{ rows: [...reservationRows, ['retired-layer', '9']] },
|
||||
{ rows: [...preWildlifeRows, ['retired-layer', '9']] },
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '00');
|
||||
@@ -217,7 +227,7 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
});
|
||||
|
||||
test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '9']] }, (cwd) => {
|
||||
withPublishedBase({ rows: [...preWildlifeRows, ['pr-a', '9']] }, (cwd) => {
|
||||
mkdirSync(path.join(cwd, 'scripts'));
|
||||
const fixtureChecker = path.join(
|
||||
cwd,
|
||||
@@ -238,7 +248,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-b', '9']],
|
||||
[...preWildlifeRows, ['pr-b', '9']],
|
||||
[{ id: 'pr-b', token: '9' }],
|
||||
);
|
||||
const beforeRebase = runCheck();
|
||||
@@ -247,7 +257,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '9'], ['pr-b', '9']],
|
||||
[...preWildlifeRows, ['pr-a', '9'], ['pr-b', '9']],
|
||||
[
|
||||
{ id: 'pr-a', token: '9' },
|
||||
{ id: 'pr-b', token: '9' },
|
||||
@@ -259,7 +269,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '00');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '9']];
|
||||
const validBaselineRows = [...preWildlifeRows, ['pr-a', '9']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]);
|
||||
const helper = spawnSync(
|
||||
process.execPath,
|
||||
@@ -275,7 +285,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
);
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '9'], ['pr-b', '00']],
|
||||
[...preWildlifeRows, ['pr-a', '9'], ['pr-b', '00']],
|
||||
[
|
||||
{ id: 'pr-a', token: '9' },
|
||||
{ id: 'pr-b', token: '00' },
|
||||
@@ -290,7 +300,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
||||
const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const allDigits = parseLayerStateTokenReservations([
|
||||
...reservationRows,
|
||||
...preWildlifeRows,
|
||||
...digitRows,
|
||||
]);
|
||||
const beforeNine = { ...allDigits };
|
||||
@@ -349,7 +359,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
|
||||
token,
|
||||
}));
|
||||
const fixtureRows = [
|
||||
...reservationRows,
|
||||
...preWildlifeRows,
|
||||
...priorDigits,
|
||||
...fixtureLayers.map(({ id, token }) => [id, token]),
|
||||
];
|
||||
@@ -358,13 +368,13 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
|
||||
assert.equal(codec.validateLayerStateRegistry(), true);
|
||||
assert.equal(
|
||||
nextLayerStateToken(
|
||||
parseLayerStateTokenReservations([...reservationRows, ...priorDigits]),
|
||||
parseLayerStateTokenReservations([...preWildlifeRows, ...priorDigits]),
|
||||
),
|
||||
'00',
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
parseLayerStateTokenReservations(preWildlifeRows),
|
||||
codec.LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
),
|
||||
true,
|
||||
@@ -395,7 +405,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
|
||||
},
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(Object.keys(published).length, 36);
|
||||
assert.equal(Object.keys(published).length, 37);
|
||||
assert.equal(published['retired-layer'], '00');
|
||||
assert.throws(
|
||||
() =>
|
||||
|
||||
@@ -33,5 +33,6 @@
|
||||
["weather-lightning", "l"],
|
||||
["weather-radar", "v"],
|
||||
["weather-satellite", "o"],
|
||||
["wildlife-sightings", "9"],
|
||||
["wind", "k"]
|
||||
]
|
||||
@@ -2,6 +2,7 @@
|
||||
export const SPRITE_LAYER_ORDER = Object.freeze([
|
||||
'cctv',
|
||||
'firms',
|
||||
'wildlife-sightings',
|
||||
'bikeshare',
|
||||
'transit',
|
||||
'transit-motion',
|
||||
|
||||
@@ -3407,5 +3407,61 @@
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي الخاص المحظور والمقيّد لدى FAA فقط، ولا يشمل UAS Facility Map أو قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "محظور",
|
||||
"Restricted": "مقيّد",
|
||||
"Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد"
|
||||
"Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد",
|
||||
"SIGHTING": "مشاهدة",
|
||||
"Show {name} sighting": "إظهار مشاهدة {name}",
|
||||
"seen {age} ago": "شوهد قبل {age}",
|
||||
"observed {age} ago": "رُصد قبل {age}",
|
||||
"Be Whale Wise": "راقب الحيتان بمسؤولية",
|
||||
"Reported by {name} ({entity})": "أبلغ عنه {name} ({entity})",
|
||||
"Reported by {name}": "أبلغ عنه {name}",
|
||||
"Salish Sea": "بحر ساليش",
|
||||
"Birds": "الطيور",
|
||||
"Mammals": "الثدييات",
|
||||
"Reptiles": "الزواحف",
|
||||
"Amphibians": "البرمائيات",
|
||||
"Ray-finned fishes": "الأسماك شعاعية الزعانف",
|
||||
"Insects": "الحشرات",
|
||||
"Arachnids": "العنكبوتيات",
|
||||
"Plants": "النباتات",
|
||||
"Fungi": "الفطريات",
|
||||
"Other animals": "حيوانات أخرى",
|
||||
"Other or unknown": "أخرى أو غير معروفة",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — انقر لإعادة المحاولة",
|
||||
"Wildlife sightings source unavailable": "مصدر مشاهدات الحياة البرية غير متاح",
|
||||
"Show only community-confirmed (research-grade) sightings": "إظهار المشاهدات المؤكدة مجتمعياً (بدرجة بحثية) فقط",
|
||||
"No recent sightings in view": "لا توجد مشاهدات حديثة في العرض",
|
||||
"Acartia feed unavailable": "تعذر الوصول إلى مصدر Acartia",
|
||||
"Show all taxa": "إظهار جميع المجموعات التصنيفية",
|
||||
"BIRDS": "الطيور",
|
||||
"Show only birds": "إظهار الطيور فقط",
|
||||
"MAMMALS": "الثدييات",
|
||||
"Show only mammals": "إظهار الثدييات فقط",
|
||||
"WHALES": "الحيتان",
|
||||
"Show only whales and dolphins (7 days)": "إظهار الحيتان والدلافين فقط (7 أيام)",
|
||||
"INSECTS": "الحشرات",
|
||||
"Show only insects": "إظهار الحشرات فقط",
|
||||
"PLANTS": "النباتات",
|
||||
"Show only plants": "إظهار النباتات فقط",
|
||||
"All rights reserved": "جميع الحقوق محفوظة",
|
||||
"Cetacean": "حوتي",
|
||||
"Wildlife Sightings": "مشاهدات الحياة البرية",
|
||||
"Exit cockpit to fly to a sighting": "اخرج من قمرة القيادة للانتقال إلى مشاهدة",
|
||||
"Exit cockpit to fly to a sighting area": "اخرج من قمرة القيادة للانتقال إلى منطقة مشاهدة",
|
||||
"Wildlife": "الحياة البرية",
|
||||
"Zoom in for local sightings": "قرّب لرؤية المشاهدات المحلية",
|
||||
"Loading photo...": "جارٍ تحميل الصورة…",
|
||||
"Photo unavailable": "الصورة غير متاحة",
|
||||
"Location obscured to a 0.2° cell": "الموقع مخفي ضمن خلية بمقدار 0.2°",
|
||||
"Photo of {name}": "صورة {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} مشاهدة ضمن النطاق",
|
||||
"Close sighting": "إغلاق المشاهدة",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "حياة برية شوهدت خلال 48 ساعة (الحيتان 7 أيام). يوضح اللون ورمز الشارة المجموعة التصنيفية. تُعرض الأنواع الحساسة في خلايا بحجم 0.2° فقط؛ وتُقرّب مشاهدات الثدييات البحرية في بحر ساليش عبر Acartia إلى 0.01°. ليست إحصاءً شاملاً.",
|
||||
"Open {name} on iNaturalist": "فتح {name} على iNaturalist",
|
||||
"Open {name} on Acartia": "فتح {name} على Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "مساهمو iNaturalist · التراخيص لكل سجل",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "مساهمو iNaturalist · Acartia {count} (بحر ساليش) · التراخيص لكل سجل"
|
||||
}
|
||||
@@ -3107,5 +3107,61 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Der Anbieter hat den API-Schlüssel abgelehnt. Den Schlüssel in den Anbieter-Einstellungen prüfen.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Projekt, Region oder Berechtigungen des Schlüssels prüfen.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen.",
|
||||
"SIGHTING": "BEOBACHTUNG",
|
||||
"Show {name} sighting": "Beobachtung von {name} anzeigen",
|
||||
"seen {age} ago": "vor {age} gesichtet",
|
||||
"observed {age} ago": "vor {age} beobachtet",
|
||||
"Be Whale Wise": "Wale respektvoll beobachten",
|
||||
"Reported by {name} ({entity})": "Gemeldet von {name} ({entity})",
|
||||
"Reported by {name}": "Gemeldet von {name}",
|
||||
"Salish Sea": "Salish-See",
|
||||
"Birds": "Vögel",
|
||||
"Mammals": "Säugetiere",
|
||||
"Reptiles": "Reptilien",
|
||||
"Amphibians": "Amphibien",
|
||||
"Ray-finned fishes": "Strahlenflosser",
|
||||
"Insects": "Insekten",
|
||||
"Arachnids": "Spinnentiere",
|
||||
"Plants": "Pflanzen",
|
||||
"Fungi": "Pilze",
|
||||
"Other animals": "Andere Tiere",
|
||||
"Other or unknown": "Andere oder unbekannt",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — zum Wiederholen klicken",
|
||||
"Wildlife sightings source unavailable": "Quelle für Naturbeobachtungen nicht verfügbar",
|
||||
"Show only community-confirmed (research-grade) sightings": "Nur gemeinschaftlich bestätigte Beobachtungen (Forschungsqualität) anzeigen",
|
||||
"No recent sightings in view": "Keine aktuellen Beobachtungen im Ausschnitt",
|
||||
"Acartia feed unavailable": "Acartia-Datenstrom nicht verfügbar",
|
||||
"Show all taxa": "Alle Artengruppen anzeigen",
|
||||
"BIRDS": "VÖGEL",
|
||||
"Show only birds": "Nur Vögel anzeigen",
|
||||
"MAMMALS": "SÄUGETIERE",
|
||||
"Show only mammals": "Nur Säugetiere anzeigen",
|
||||
"WHALES": "WALE",
|
||||
"Show only whales and dolphins (7 days)": "Nur Wale und Delfine anzeigen (7 Tage)",
|
||||
"INSECTS": "INSEKTEN",
|
||||
"Show only insects": "Nur Insekten anzeigen",
|
||||
"PLANTS": "PFLANZEN",
|
||||
"Show only plants": "Nur Pflanzen anzeigen",
|
||||
"All rights reserved": "Alle Rechte vorbehalten",
|
||||
"Cetacean": "Wal oder Delfin",
|
||||
"Wildlife Sightings": "Naturbeobachtungen",
|
||||
"Exit cockpit to fly to a sighting": "Cockpit verlassen, um zu einer Beobachtung zu fliegen",
|
||||
"Exit cockpit to fly to a sighting area": "Cockpit verlassen, um zu einem Beobachtungsgebiet zu fliegen",
|
||||
"Wildlife": "Tierwelt",
|
||||
"Zoom in for local sightings": "Für lokale Beobachtungen vergrößern",
|
||||
"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",
|
||||
"Photo of {name}": "Foto von {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} Sichtungen im Ausschnitt",
|
||||
"Close sighting": "Sichtung schließen",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "In den letzten 48 h beobachtete Wildtiere (Wale 7 Tage). Farbe und Abzeichensymbol zeigen die Artengruppe. Empfindliche Arten erscheinen nur als 0,2°-Zellen; Acartia-Meldungen zu Meeressäugern aus der Salish-See werden auf 0,01° gerundet. Keine Bestandszählung.",
|
||||
"Open {name} on iNaturalist": "{name} auf iNaturalist öffnen",
|
||||
"Open {name} on Acartia": "{name} auf Acartia öffnen",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist-Beitragende · Lizenzen je Eintrag",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist-Beitragende · Acartia {count} (Salish-See) · Lizenzen je Eintrag"
|
||||
}
|
||||
@@ -3182,5 +3182,61 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "El proveedor rechazó la clave de API. Revísala en Ajustes de proveedores.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Demasiadas solicitudes de chat. Vuelve a intentarlo en {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "El proveedor denegó el acceso a esta clave. Revisa el proyecto, la región o los permisos de la clave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes.",
|
||||
"SIGHTING": "AVISTAMIENTO",
|
||||
"Show {name} sighting": "Mostrar avistamiento de {name}",
|
||||
"seen {age} ago": "visto hace {age}",
|
||||
"observed {age} ago": "observado hace {age}",
|
||||
"Be Whale Wise": "Observa cetáceos con respeto",
|
||||
"Reported by {name} ({entity})": "Reportado por {name} ({entity})",
|
||||
"Reported by {name}": "Reportado por {name}",
|
||||
"Salish Sea": "Mar de Salish",
|
||||
"Birds": "Aves",
|
||||
"Mammals": "Mamíferos",
|
||||
"Reptiles": "Reptiles",
|
||||
"Amphibians": "Anfibios",
|
||||
"Ray-finned fishes": "Peces de aletas radiadas",
|
||||
"Insects": "Insectos",
|
||||
"Arachnids": "Arácnidos",
|
||||
"Plants": "Plantas",
|
||||
"Fungi": "Hongos",
|
||||
"Other animals": "Otros animales",
|
||||
"Other or unknown": "Otros o desconocidos",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — haz clic para reintentar",
|
||||
"Wildlife sightings source unavailable": "Fuente de avistamientos no disponible",
|
||||
"Show only community-confirmed (research-grade) sightings": "Mostrar solo observaciones confirmadas por la comunidad (grado de investigación)",
|
||||
"No recent sightings in view": "No hay avistamientos recientes en la vista",
|
||||
"Acartia feed unavailable": "Fuente Acartia no disponible",
|
||||
"Show all taxa": "Mostrar todos los grupos taxonómicos",
|
||||
"BIRDS": "AVES",
|
||||
"Show only birds": "Mostrar solo aves",
|
||||
"MAMMALS": "MAMÍFEROS",
|
||||
"Show only mammals": "Mostrar solo mamíferos",
|
||||
"WHALES": "CETÁCEOS",
|
||||
"Show only whales and dolphins (7 days)": "Mostrar solo ballenas y delfines (7 días)",
|
||||
"INSECTS": "INSECTOS",
|
||||
"Show only insects": "Mostrar solo insectos",
|
||||
"PLANTS": "PLANTAS",
|
||||
"Show only plants": "Mostrar solo plantas",
|
||||
"All rights reserved": "Todos los derechos reservados",
|
||||
"Cetacean": "Cetáceo",
|
||||
"Wildlife Sightings": "Avistamientos de fauna y flora",
|
||||
"Exit cockpit to fly to a sighting": "Sal de la cabina para volar a un avistamiento",
|
||||
"Exit cockpit to fly to a sighting area": "Sal de la cabina para volar a un área de avistamiento",
|
||||
"Wildlife": "Vida silvestre",
|
||||
"Zoom in for local sightings": "Acerca para ver avistamientos locales",
|
||||
"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°",
|
||||
"Photo of {name}": "Foto de {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} avistamientos en la vista",
|
||||
"Close sighting": "Cerrar avistamiento",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna observada en las últimas 48 h (cetáceos 7 días). El color y el símbolo de la insignia indican el grupo taxonómico. Las especies sensibles solo se muestran en celdas de 0,2°; los informes de mamíferos marinos de Acartia en el mar de Salish se redondean a 0,01°. No es un censo.",
|
||||
"Open {name} on iNaturalist": "Abrir {name} en iNaturalist",
|
||||
"Open {name} on Acartia": "Abrir {name} en Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Colaboradores de iNaturalist · licencias por registro",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores de iNaturalist · Acartia {count} (mar de Salish) · licencias por registro"
|
||||
}
|
||||
@@ -3182,5 +3182,61 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Le fournisseur a refusé la clé API. Vérifiez-la dans les paramètres des fournisseurs.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de discussion. Réessayez dans {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.",
|
||||
"SIGHTING": "OBSERVATION",
|
||||
"Show {name} sighting": "Afficher l’observation de {name}",
|
||||
"seen {age} ago": "vu il y a {age}",
|
||||
"observed {age} ago": "observé il y a {age}",
|
||||
"Be Whale Wise": "Respectez les cétacés",
|
||||
"Reported by {name} ({entity})": "Signalé par {name} ({entity})",
|
||||
"Reported by {name}": "Signalé par {name}",
|
||||
"Salish Sea": "Mer des Salish",
|
||||
"Birds": "Oiseaux",
|
||||
"Mammals": "Mammifères",
|
||||
"Reptiles": "Reptiles",
|
||||
"Amphibians": "Amphibiens",
|
||||
"Ray-finned fishes": "Poissons à nageoires rayonnées",
|
||||
"Insects": "Insectes",
|
||||
"Arachnids": "Arachnides",
|
||||
"Plants": "Plantes",
|
||||
"Fungi": "Champignons",
|
||||
"Other animals": "Autres animaux",
|
||||
"Other or unknown": "Autres ou inconnus",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — cliquer pour réessayer",
|
||||
"Wildlife sightings source unavailable": "Source des observations naturalistes indisponible",
|
||||
"Show only community-confirmed (research-grade) sightings": "Afficher uniquement les observations confirmées par la communauté (qualité recherche)",
|
||||
"No recent sightings in view": "Aucune observation récente dans la vue",
|
||||
"Acartia feed unavailable": "Flux Acartia indisponible",
|
||||
"Show all taxa": "Afficher tous les groupes taxonomiques",
|
||||
"BIRDS": "OISEAUX",
|
||||
"Show only birds": "Afficher uniquement les oiseaux",
|
||||
"MAMMALS": "MAMMIFÈRES",
|
||||
"Show only mammals": "Afficher uniquement les mammifères",
|
||||
"WHALES": "CÉTACÉS",
|
||||
"Show only whales and dolphins (7 days)": "Afficher uniquement les baleines et dauphins (7 jours)",
|
||||
"INSECTS": "INSECTES",
|
||||
"Show only insects": "Afficher uniquement les insectes",
|
||||
"PLANTS": "PLANTES",
|
||||
"Show only plants": "Afficher uniquement les plantes",
|
||||
"All rights reserved": "Tous droits réservés",
|
||||
"Cetacean": "Cétacé",
|
||||
"Wildlife Sightings": "Observations naturalistes",
|
||||
"Exit cockpit to fly to a sighting": "Quittez le cockpit pour rejoindre une observation",
|
||||
"Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d’observation",
|
||||
"Wildlife": "Faune et flore",
|
||||
"Zoom in for local sightings": "Zoomez pour les observations locales",
|
||||
"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°",
|
||||
"Photo of {name}": "Photo de {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} observations dans la vue",
|
||||
"Close sighting": "Fermer l’observation",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins Acartia en mer des Salish sont arrondis à 0,01°. Pas un recensement.",
|
||||
"Open {name} on iNaturalist": "Ouvrir {name} sur iNaturalist",
|
||||
"Open {name} on Acartia": "Ouvrir {name} sur Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation"
|
||||
}
|
||||
@@ -3182,5 +3182,61 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Il provider ha rifiutato la chiave API. Controllala in Impostazioni provider.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l'accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni.",
|
||||
"SIGHTING": "AVVISTAMENTO",
|
||||
"Show {name} sighting": "Mostra avvistamento di {name}",
|
||||
"seen {age} ago": "avvistato {age} fa",
|
||||
"observed {age} ago": "osservato {age} fa",
|
||||
"Be Whale Wise": "Rispetta i cetacei",
|
||||
"Reported by {name} ({entity})": "Segnalato da {name} ({entity})",
|
||||
"Reported by {name}": "Segnalato da {name}",
|
||||
"Salish Sea": "Mare dei Salish",
|
||||
"Birds": "Uccelli",
|
||||
"Mammals": "Mammiferi",
|
||||
"Reptiles": "Rettili",
|
||||
"Amphibians": "Anfibi",
|
||||
"Ray-finned fishes": "Pesci con pinne raggiate",
|
||||
"Insects": "Insetti",
|
||||
"Arachnids": "Aracnidi",
|
||||
"Plants": "Piante",
|
||||
"Fungi": "Funghi",
|
||||
"Other animals": "Altri animali",
|
||||
"Other or unknown": "Altri o sconosciuti",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — fai clic per riprovare",
|
||||
"Wildlife sightings source unavailable": "Fonte degli avvistamenti non disponibile",
|
||||
"Show only community-confirmed (research-grade) sightings": "Mostra solo osservazioni confermate dalla comunità (grado di ricerca)",
|
||||
"No recent sightings in view": "Nessun avvistamento recente nella vista",
|
||||
"Acartia feed unavailable": "Feed Acartia non disponibile",
|
||||
"Show all taxa": "Mostra tutti i gruppi tassonomici",
|
||||
"BIRDS": "UCCELLI",
|
||||
"Show only birds": "Mostra solo uccelli",
|
||||
"MAMMALS": "MAMMIFERI",
|
||||
"Show only mammals": "Mostra solo mammiferi",
|
||||
"WHALES": "CETACEI",
|
||||
"Show only whales and dolphins (7 days)": "Mostra solo balene e delfini (7 giorni)",
|
||||
"INSECTS": "INSETTI",
|
||||
"Show only insects": "Mostra solo insetti",
|
||||
"PLANTS": "PIANTE",
|
||||
"Show only plants": "Mostra solo piante",
|
||||
"All rights reserved": "Tutti i diritti riservati",
|
||||
"Cetacean": "Cetaceo",
|
||||
"Wildlife Sightings": "Avvistamenti naturalistici",
|
||||
"Exit cockpit to fly to a sighting": "Esci dalla cabina per volare a un avvistamento",
|
||||
"Exit cockpit to fly to a sighting area": "Esci dalla cabina per volare a un’area di avvistamento",
|
||||
"Wildlife": "Natura",
|
||||
"Zoom in for local sightings": "Ingrandisci per gli avvistamenti locali",
|
||||
"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°",
|
||||
"Photo of {name}": "Foto di {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} avvistamenti nella vista",
|
||||
"Close sighting": "Chiudi avvistamento",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna osservata nelle ultime 48 h (cetacei 7 giorni). Colore e simbolo del contrassegno indicano il gruppo tassonomico. Le specie sensibili sono mostrate solo in celle di 0,2°; le segnalazioni di mammiferi marini di Acartia nel mar dei Salish sono arrotondate a 0,01°. Non è un censimento.",
|
||||
"Open {name} on iNaturalist": "Apri {name} su iNaturalist",
|
||||
"Open {name} on Acartia": "Apri {name} su Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Contributori di iNaturalist · licenze per osservazione",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributori di iNaturalist · Acartia {count} (Mare dei Salish) · licenze per osservazione"
|
||||
}
|
||||
@@ -3032,5 +3032,61 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "プロバイダーがAPIキーを拒否しました。プロバイダー設定で確認してください。",
|
||||
"Too many chat requests. Try again in {seconds} s.": "チャットのリクエストが多すぎます。{seconds}秒後にもう一度お試しください。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "プロバイダーがこのキーでのアクセスを拒否しました。キーのプロジェクト、リージョン、権限を確認してください。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。"
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。",
|
||||
"SIGHTING": "目撃情報",
|
||||
"Show {name} sighting": "{name}の目撃情報を表示",
|
||||
"seen {age} ago": "{age}前に目撃",
|
||||
"observed {age} ago": "{age}前に観察",
|
||||
"Be Whale Wise": "クジラに配慮しましょう",
|
||||
"Reported by {name} ({entity})": "報告元 {name}({entity})",
|
||||
"Reported by {name}": "報告元 {name}",
|
||||
"Salish Sea": "セイリッシュ海",
|
||||
"Birds": "鳥類",
|
||||
"Mammals": "哺乳類",
|
||||
"Reptiles": "爬虫類",
|
||||
"Amphibians": "両生類",
|
||||
"Ray-finned fishes": "条鰭類",
|
||||
"Insects": "昆虫",
|
||||
"Arachnids": "クモ類",
|
||||
"Plants": "植物",
|
||||
"Fungi": "菌類",
|
||||
"Other animals": "その他の動物",
|
||||
"Other or unknown": "その他・不明",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — クリックして再試行",
|
||||
"Wildlife sightings source unavailable": "野生生物の目撃情報源を利用できません",
|
||||
"Show only community-confirmed (research-grade) sightings": "コミュニティが確認した研究グレードの目撃情報のみ表示",
|
||||
"No recent sightings in view": "表示範囲に最近の目撃情報はありません",
|
||||
"Acartia feed unavailable": "Acartiaのフィードを利用できません",
|
||||
"Show all taxa": "すべての分類群を表示",
|
||||
"BIRDS": "鳥類",
|
||||
"Show only birds": "鳥類のみ表示",
|
||||
"MAMMALS": "哺乳類",
|
||||
"Show only mammals": "哺乳類のみ表示",
|
||||
"WHALES": "クジラ類",
|
||||
"Show only whales and dolphins (7 days)": "クジラとイルカのみ表示(7日間)",
|
||||
"INSECTS": "昆虫",
|
||||
"Show only insects": "昆虫のみ表示",
|
||||
"PLANTS": "植物",
|
||||
"Show only plants": "植物のみ表示",
|
||||
"All rights reserved": "無断転載禁止",
|
||||
"Cetacean": "クジラ類",
|
||||
"Wildlife Sightings": "野生生物の目撃情報",
|
||||
"Exit cockpit to fly to a sighting": "目撃地点に移動するにはコックピットを終了",
|
||||
"Exit cockpit to fly to a sighting area": "目撃範囲に移動するにはコックピットを終了",
|
||||
"Wildlife": "野生生物",
|
||||
"Zoom in for local sightings": "拡大して地域の目撃情報を表示",
|
||||
"Loading photo...": "写真を読み込み中…",
|
||||
"Photo unavailable": "写真を表示できません",
|
||||
"Location obscured to a 0.2° cell": "位置は0.2°のセル内に秘匿されています",
|
||||
"Photo of {name}": "{name}の写真",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "表示範囲に{count}件の目撃情報",
|
||||
"Close sighting": "目撃情報を閉じる",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "過去48時間以内に観察された野生生物(クジラ類は7日間)。色とバッジのアイコンは分類群を表します。保護が必要な種は0.2°の区画のみ表示し、セイリッシュ海のAcartia海生哺乳類目撃情報は0.01°に丸めます。個体数調査ではありません。",
|
||||
"Open {name} on iNaturalist": "iNaturalistで{name}を開く",
|
||||
"Open {name} on Acartia": "Acartiaで{name}を開く",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist の投稿者 · ライセンスは記録ごと",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist の投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと"
|
||||
}
|
||||
@@ -3182,5 +3182,61 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "O provedor recusou a chave de API. Verifique-a em \"Configurações de provedores\".",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Chat: limite de requisições atingido. Tente novamente em {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "O provedor negou o acesso para esta chave. Verifique o projeto, a região ou as permissões da chave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\"."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\".",
|
||||
"SIGHTING": "AVISTAMENTO",
|
||||
"Show {name} sighting": "Mostrar avistamento de {name}",
|
||||
"seen {age} ago": "visto há {age}",
|
||||
"observed {age} ago": "observado há {age}",
|
||||
"Be Whale Wise": "Respeite os cetáceos",
|
||||
"Reported by {name} ({entity})": "Relatado por {name} ({entity})",
|
||||
"Reported by {name}": "Relatado por {name}",
|
||||
"Salish Sea": "Mar de Salish",
|
||||
"Birds": "Aves",
|
||||
"Mammals": "Mamíferos",
|
||||
"Reptiles": "Répteis",
|
||||
"Amphibians": "Anfíbios",
|
||||
"Ray-finned fishes": "Peixes de nadadeiras raiadas",
|
||||
"Insects": "Insetos",
|
||||
"Arachnids": "Aracnídeos",
|
||||
"Plants": "Plantas",
|
||||
"Fungi": "Fungos",
|
||||
"Other animals": "Outros animais",
|
||||
"Other or unknown": "Outros ou desconhecidos",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — clique para tentar novamente",
|
||||
"Wildlife sightings source unavailable": "Fonte de avistamentos indisponível",
|
||||
"Show only community-confirmed (research-grade) sightings": "Mostrar apenas observações confirmadas pela comunidade (grau de pesquisa)",
|
||||
"No recent sightings in view": "Nenhum avistamento recente na vista",
|
||||
"Acartia feed unavailable": "Feed Acartia indisponível",
|
||||
"Show all taxa": "Mostrar todos os grupos taxonômicos",
|
||||
"BIRDS": "AVES",
|
||||
"Show only birds": "Mostrar apenas aves",
|
||||
"MAMMALS": "MAMÍFEROS",
|
||||
"Show only mammals": "Mostrar apenas mamíferos",
|
||||
"WHALES": "CETÁCEOS",
|
||||
"Show only whales and dolphins (7 days)": "Mostrar apenas baleias e golfinhos (7 dias)",
|
||||
"INSECTS": "INSETOS",
|
||||
"Show only insects": "Mostrar apenas insetos",
|
||||
"PLANTS": "PLANTAS",
|
||||
"Show only plants": "Mostrar apenas plantas",
|
||||
"All rights reserved": "Todos os direitos reservados",
|
||||
"Cetacean": "Cetáceo",
|
||||
"Wildlife Sightings": "Avistamentos de vida selvagem",
|
||||
"Exit cockpit to fly to a sighting": "Saia da cabine para voar até um avistamento",
|
||||
"Exit cockpit to fly to a sighting area": "Saia da cabine para voar até uma área de avistamento",
|
||||
"Wildlife": "Vida selvagem",
|
||||
"Zoom in for local sightings": "Amplie para ver avistamentos locais",
|
||||
"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°",
|
||||
"Photo of {name}": "Foto de {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} avistamentos na vista",
|
||||
"Close sighting": "Fechar avistamento",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna observada nas últimas 48 h (cetáceos 7 dias). A cor e o símbolo do emblema indicam o grupo taxonômico. Espécies sensíveis aparecem apenas em células de 0,2°; os relatos de mamíferos marinhos da Acartia no mar de Salish são arredondados para 0,01°. Não é um censo.",
|
||||
"Open {name} on iNaturalist": "Abrir {name} no iNaturalist",
|
||||
"Open {name} on Acartia": "Abrir {name} no Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Colaboradores do iNaturalist · licenças por registro",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores do iNaturalist · Acartia {count} (mar de Salish) · licenças por registro"
|
||||
}
|
||||
@@ -3257,5 +3257,61 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Провайдер отклонил ключ API. Проверьте его в «Настройках провайдеров».",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Слишком много запросов в чате. Повторите попытку через {seconds} с.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Провайдер отказал этому ключу в доступе. Проверьте проект, регион или разрешения ключа.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках.",
|
||||
"SIGHTING": "НАБЛЮДЕНИЕ",
|
||||
"Show {name} sighting": "Показать наблюдение: {name}",
|
||||
"seen {age} ago": "замечено {age} назад",
|
||||
"observed {age} ago": "наблюдалось {age} назад",
|
||||
"Be Whale Wise": "Наблюдайте китов бережно",
|
||||
"Reported by {name} ({entity})": "Сообщил: {name} ({entity})",
|
||||
"Reported by {name}": "Сообщил: {name}",
|
||||
"Salish Sea": "Селишское море",
|
||||
"Birds": "Птицы",
|
||||
"Mammals": "Млекопитающие",
|
||||
"Reptiles": "Пресмыкающиеся",
|
||||
"Amphibians": "Земноводные",
|
||||
"Ray-finned fishes": "Лучепёрые рыбы",
|
||||
"Insects": "Насекомые",
|
||||
"Arachnids": "Паукообразные",
|
||||
"Plants": "Растения",
|
||||
"Fungi": "Грибы",
|
||||
"Other animals": "Другие животные",
|
||||
"Other or unknown": "Прочие или неизвестные",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — нажмите, чтобы повторить",
|
||||
"Wildlife sightings source unavailable": "Источник наблюдений живой природы недоступен",
|
||||
"Show only community-confirmed (research-grade) sightings": "Показать только подтверждённые сообществом наблюдения исследовательского уровня",
|
||||
"No recent sightings in view": "Нет недавних наблюдений в области обзора",
|
||||
"Acartia feed unavailable": "Лента Acartia недоступна",
|
||||
"Show all taxa": "Показать все группы организмов",
|
||||
"BIRDS": "ПТИЦЫ",
|
||||
"Show only birds": "Показать только птиц",
|
||||
"MAMMALS": "МЛЕКОПИТАЮЩИЕ",
|
||||
"Show only mammals": "Показать только млекопитающих",
|
||||
"WHALES": "КИТООБРАЗНЫЕ",
|
||||
"Show only whales and dolphins (7 days)": "Показать только китов и дельфинов (7 дней)",
|
||||
"INSECTS": "НАСЕКОМЫЕ",
|
||||
"Show only insects": "Показать только насекомых",
|
||||
"PLANTS": "РАСТЕНИЯ",
|
||||
"Show only plants": "Показать только растения",
|
||||
"All rights reserved": "Все права защищены",
|
||||
"Cetacean": "Китообразное",
|
||||
"Wildlife Sightings": "Наблюдения живой природы",
|
||||
"Exit cockpit to fly to a sighting": "Выйдите из кабины, чтобы перейти к наблюдению",
|
||||
"Exit cockpit to fly to a sighting area": "Выйдите из кабины, чтобы перейти к области наблюдения",
|
||||
"Wildlife": "Живая природа",
|
||||
"Zoom in for local sightings": "Приблизьте для местных наблюдений",
|
||||
"Loading photo...": "Загрузка фото…",
|
||||
"Photo unavailable": "Фото недоступно",
|
||||
"Location obscured to a 0.2° cell": "Местоположение скрыто в ячейке 0,2°",
|
||||
"Photo of {name}": "Фото {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} наблюдений в области просмотра",
|
||||
"Close sighting": "Закрыть наблюдение",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Наблюдения дикой природы за 48 ч (китообразные — 7 дней). Цвет и символ значка обозначают таксономическую группу. Уязвимые виды показаны только ячейками 0,2°; сообщения Acartia о морских млекопитающих моря Селиш округлены до 0,01°. Не перепись.",
|
||||
"Open {name} on iNaturalist": "Открыть {name} в iNaturalist",
|
||||
"Open {name} on Acartia": "Открыть {name} в Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Участники iNaturalist · лицензия у каждой записи",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Сэлиш) · лицензия у каждой записи"
|
||||
}
|
||||
@@ -3257,5 +3257,61 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Постачальник відхилив ключ API. Перевірте його в «Налаштуваннях постачальників».",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Забагато запитів до чату. Спробуйте ще раз через {seconds} с.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Постачальник відмовив цьому ключу в доступі. Перевірте проєкт, регіон або дозволи ключа.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях.",
|
||||
"SIGHTING": "СПОСТЕРЕЖЕННЯ",
|
||||
"Show {name} sighting": "Показати спостереження: {name}",
|
||||
"seen {age} ago": "помічено {age} тому",
|
||||
"observed {age} ago": "спостерігалося {age} тому",
|
||||
"Be Whale Wise": "Спостерігайте за китами дбайливо",
|
||||
"Reported by {name} ({entity})": "Повідомив: {name} ({entity})",
|
||||
"Reported by {name}": "Повідомив: {name}",
|
||||
"Salish Sea": "Салішське море",
|
||||
"Birds": "Птахи",
|
||||
"Mammals": "Ссавці",
|
||||
"Reptiles": "Плазуни",
|
||||
"Amphibians": "Земноводні",
|
||||
"Ray-finned fishes": "Променепері риби",
|
||||
"Insects": "Комахи",
|
||||
"Arachnids": "Павукоподібні",
|
||||
"Plants": "Рослини",
|
||||
"Fungi": "Гриби",
|
||||
"Other animals": "Інші тварини",
|
||||
"Other or unknown": "Інші або невідомі",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — натисніть, щоб повторити",
|
||||
"Wildlife sightings source unavailable": "Джерело спостережень живої природи недоступне",
|
||||
"Show only community-confirmed (research-grade) sightings": "Показати лише підтверджені спільнотою спостереження дослідницького рівня",
|
||||
"No recent sightings in view": "Немає недавніх спостережень в області огляду",
|
||||
"Acartia feed unavailable": "Стрічка Acartia недоступна",
|
||||
"Show all taxa": "Показати всі групи організмів",
|
||||
"BIRDS": "ПТАХИ",
|
||||
"Show only birds": "Показати лише птахів",
|
||||
"MAMMALS": "ССАВЦІ",
|
||||
"Show only mammals": "Показати лише ссавців",
|
||||
"WHALES": "КИТОПОДІБНІ",
|
||||
"Show only whales and dolphins (7 days)": "Показати лише китів і дельфінів (7 днів)",
|
||||
"INSECTS": "КОМАХИ",
|
||||
"Show only insects": "Показати лише комах",
|
||||
"PLANTS": "РОСЛИНИ",
|
||||
"Show only plants": "Показати лише рослини",
|
||||
"All rights reserved": "Усі права захищено",
|
||||
"Cetacean": "Китоподібне",
|
||||
"Wildlife Sightings": "Спостереження живої природи",
|
||||
"Exit cockpit to fly to a sighting": "Вийдіть із кабіни, щоб перейти до спостереження",
|
||||
"Exit cockpit to fly to a sighting area": "Вийдіть із кабіни, щоб перейти до області спостереження",
|
||||
"Wildlife": "Жива природа",
|
||||
"Zoom in for local sightings": "Наблизьте для місцевих спостережень",
|
||||
"Loading photo...": "Завантаження фото…",
|
||||
"Photo unavailable": "Фото недоступне",
|
||||
"Location obscured to a 0.2° cell": "Місцеположення приховано в комірці 0,2°",
|
||||
"Photo of {name}": "Фото {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} спостережень у полі зору",
|
||||
"Close sighting": "Закрити спостереження",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Спостереження дикої природи за 48 год (китоподібні — 7 днів). Колір і символ значка позначають таксономічну групу. Вразливі види показано лише клітинками 0,2°; повідомлення Acartia про морських ссавців моря Селіш округлено до 0,01°. Не перепис.",
|
||||
"Open {name} on iNaturalist": "Відкрити {name} в iNaturalist",
|
||||
"Open {name} on Acartia": "Відкрити {name} в Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Учасники iNaturalist · ліцензія в кожного запису",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Учасники iNaturalist · Acartia {count} (море Селіш) · ліцензія в кожного запису"
|
||||
}
|
||||
@@ -3032,5 +3032,61 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "服务商拒绝了 API 密钥。请在服务商设置中检查。",
|
||||
"Too many chat requests. Try again in {seconds} s.": "聊天请求过多。请在 {seconds} 秒后重试。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "服务商拒绝了此密钥的访问请求。请检查该密钥所属的项目、区域或权限。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。"
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。",
|
||||
"SIGHTING": "目击",
|
||||
"Show {name} sighting": "显示{name}目击",
|
||||
"seen {age} ago": "{age}前目击",
|
||||
"observed {age} ago": "{age}前观察",
|
||||
"Be Whale Wise": "负责任地观鲸",
|
||||
"Reported by {name} ({entity})": "报告方 {name}({entity})",
|
||||
"Reported by {name}": "报告方 {name}",
|
||||
"Salish Sea": "萨利希海",
|
||||
"Birds": "鸟类",
|
||||
"Mammals": "哺乳类",
|
||||
"Reptiles": "爬行动物",
|
||||
"Amphibians": "两栖动物",
|
||||
"Ray-finned fishes": "辐鳍鱼类",
|
||||
"Insects": "昆虫",
|
||||
"Arachnids": "蛛形动物",
|
||||
"Plants": "植物",
|
||||
"Fungi": "真菌",
|
||||
"Other animals": "其他动物",
|
||||
"Other or unknown": "其他或未知",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — 点击重试",
|
||||
"Wildlife sightings source unavailable": "野生生物目击来源不可用",
|
||||
"Show only community-confirmed (research-grade) sightings": "仅显示社区确认(研究级)的目击",
|
||||
"No recent sightings in view": "视野内无近期目击",
|
||||
"Acartia feed unavailable": "Acartia数据源不可用",
|
||||
"Show all taxa": "显示所有分类群",
|
||||
"BIRDS": "鸟类",
|
||||
"Show only birds": "仅显示鸟类",
|
||||
"MAMMALS": "哺乳类",
|
||||
"Show only mammals": "仅显示哺乳类",
|
||||
"WHALES": "鲸类",
|
||||
"Show only whales and dolphins (7 days)": "仅显示鲸和海豚(7天)",
|
||||
"INSECTS": "昆虫",
|
||||
"Show only insects": "仅显示昆虫",
|
||||
"PLANTS": "植物",
|
||||
"Show only plants": "仅显示植物",
|
||||
"All rights reserved": "保留所有权利",
|
||||
"Cetacean": "鲸类",
|
||||
"Wildlife Sightings": "野生生物目击",
|
||||
"Exit cockpit to fly to a sighting": "退出驾驶舱以飞往目击地点",
|
||||
"Exit cockpit to fly to a sighting area": "退出驾驶舱以飞往目击区域",
|
||||
"Wildlife": "野生生物",
|
||||
"Zoom in for local sightings": "放大查看当地目击",
|
||||
"Loading photo...": "正在加载照片…",
|
||||
"Photo unavailable": "照片不可用",
|
||||
"Location obscured to a 0.2° cell": "位置已模糊至0.2°网格",
|
||||
"Photo of {name}": "{name}的照片",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "视野内有 {count} 次目击",
|
||||
"Close sighting": "关闭目击记录",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "近48小时内观察到的野生生物(鲸类为7天)。颜色与徽章图标表示分类群。敏感物种仅以0.2°网格显示;Acartia提供的萨利希海海洋哺乳动物目击位置四舍五入到0.01°。并非种群普查。",
|
||||
"Open {name} on iNaturalist": "在 iNaturalist 上打开 {name}",
|
||||
"Open {name} on Acartia": "在 Acartia 上打开 {name}",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist 贡献者 · 各记录单独授权",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海) · 各记录单独授权"
|
||||
}
|
||||
@@ -3032,5 +3032,61 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "服務商拒絕了 API 密鑰。請在服務商設置中檢查。",
|
||||
"Too many chat requests. Try again in {seconds} s.": "聊天請求過多。請在 {seconds} 秒後重試。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "服務商拒絕了此密鑰的訪問請求。請檢查該密鑰所屬的項目、區域或權限。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。"
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。",
|
||||
"SIGHTING": "目擊",
|
||||
"Show {name} sighting": "顯示{name}目擊",
|
||||
"seen {age} ago": "{age}前目擊",
|
||||
"observed {age} ago": "{age}前觀察",
|
||||
"Be Whale Wise": "負責任地賞鯨",
|
||||
"Reported by {name} ({entity})": "通報方 {name}({entity})",
|
||||
"Reported by {name}": "通報方 {name}",
|
||||
"Salish Sea": "薩利希海",
|
||||
"Birds": "鳥類",
|
||||
"Mammals": "哺乳類",
|
||||
"Reptiles": "爬行動物",
|
||||
"Amphibians": "兩棲動物",
|
||||
"Ray-finned fishes": "輻鰭魚類",
|
||||
"Insects": "昆蟲",
|
||||
"Arachnids": "蛛形動物",
|
||||
"Plants": "植物",
|
||||
"Fungi": "真菌",
|
||||
"Other animals": "其他動物",
|
||||
"Other or unknown": "其他或未知",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — 點擊重試",
|
||||
"Wildlife sightings source unavailable": "野生生物目擊來源無法使用",
|
||||
"Show only community-confirmed (research-grade) sightings": "僅顯示社群確認(研究級)的目擊",
|
||||
"No recent sightings in view": "視野內無近期目擊",
|
||||
"Acartia feed unavailable": "Acartia資料來源無法使用",
|
||||
"Show all taxa": "顯示所有分類群",
|
||||
"BIRDS": "鳥類",
|
||||
"Show only birds": "僅顯示鳥類",
|
||||
"MAMMALS": "哺乳類",
|
||||
"Show only mammals": "僅顯示哺乳類",
|
||||
"WHALES": "鯨類",
|
||||
"Show only whales and dolphins (7 days)": "僅顯示鯨和海豚(7天)",
|
||||
"INSECTS": "昆蟲",
|
||||
"Show only insects": "僅顯示昆蟲",
|
||||
"PLANTS": "植物",
|
||||
"Show only plants": "僅顯示植物",
|
||||
"All rights reserved": "保留所有權利",
|
||||
"Cetacean": "鯨類",
|
||||
"Wildlife Sightings": "野生生物目擊",
|
||||
"Exit cockpit to fly to a sighting": "退出駕駛艙以飛往目擊地點",
|
||||
"Exit cockpit to fly to a sighting area": "退出駕駛艙以飛往目擊區域",
|
||||
"Wildlife": "野生生物",
|
||||
"Zoom in for local sightings": "放大查看當地目擊",
|
||||
"Loading photo...": "正在載入照片…",
|
||||
"Photo unavailable": "照片無法使用",
|
||||
"Location obscured to a 0.2° cell": "位置已模糊至0.2°網格",
|
||||
"Photo of {name}": "{name}的照片",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "視野內有 {count} 次目擊",
|
||||
"Close sighting": "關閉目擊紀錄",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "近48小時內觀察到的野生生物(鯨類為7天)。顏色與徽章圖示表示分類群。敏感物種僅以0.2°網格顯示;Acartia提供的薩利希海海洋哺乳動物目擊位置四捨五入到0.01°。並非族群普查。",
|
||||
"Open {name} on iNaturalist": "在 iNaturalist 上打開 {name}",
|
||||
"Open {name} on Acartia": "在 Acartia 上打開 {name}",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist 貢獻者 · 各紀錄個別授權",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海) · 各紀錄個別授權"
|
||||
}
|
||||
@@ -4,10 +4,12 @@
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"Acartia",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CC BY 4.0",
|
||||
"CCTV",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
@@ -28,8 +30,11 @@
|
||||
"SRC {value}",
|
||||
"Sentinel-2",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"dB",
|
||||
"hPa",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗",
|
||||
"{source} HTTP {status}",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
@@ -40,6 +45,7 @@
|
||||
"AIS: {name}",
|
||||
"ANIME",
|
||||
"AUTO",
|
||||
"Acartia",
|
||||
"Alternative",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
@@ -50,6 +56,7 @@
|
||||
"Bloom",
|
||||
"Blues",
|
||||
"Bus",
|
||||
"CC BY 4.0",
|
||||
"CCTV",
|
||||
"CHAT",
|
||||
"COCKPIT",
|
||||
@@ -178,11 +185,14 @@
|
||||
"WSW",
|
||||
"Washington DC",
|
||||
"Wind",
|
||||
"acartia.io ↗",
|
||||
"dB",
|
||||
"feed status|live",
|
||||
"fire behavior|Minimal",
|
||||
"ft",
|
||||
"hPa",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗",
|
||||
"km",
|
||||
"km/h",
|
||||
"min",
|
||||
@@ -334,15 +344,24 @@
|
||||
"≥ {value} MW",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"ANIMAL · {name}",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"Reptiles",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
],
|
||||
"fr": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"ALTITUDE",
|
||||
"ALTITUDE · FT",
|
||||
"ANIMAL · {name}",
|
||||
"ANIME",
|
||||
"AUTO",
|
||||
"Acartia",
|
||||
"Action",
|
||||
"Allocation",
|
||||
"Altitude {altitude}",
|
||||
@@ -361,6 +380,7 @@
|
||||
"CALIBRATION",
|
||||
"CARGO",
|
||||
"CAUSE",
|
||||
"CC BY 4.0",
|
||||
"CCTV",
|
||||
"CCTV OFF",
|
||||
"CCTV ON",
|
||||
@@ -437,6 +457,7 @@
|
||||
"OK",
|
||||
"ON",
|
||||
"ON/OFF",
|
||||
"Obs. {licence}",
|
||||
"Oldies",
|
||||
"PAUSE",
|
||||
"POSITION {age}",
|
||||
@@ -452,8 +473,10 @@
|
||||
"RADAR · {units}",
|
||||
"RADIO",
|
||||
"REC",
|
||||
"REPTILE · {name}",
|
||||
"Radio",
|
||||
"Reggae",
|
||||
"Reptiles",
|
||||
"Rock",
|
||||
"S2 GhostMaps",
|
||||
"SAR",
|
||||
@@ -488,10 +511,13 @@
|
||||
"VOLUME",
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"audio {state}",
|
||||
"dB",
|
||||
"ft",
|
||||
"hPa",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗",
|
||||
"image",
|
||||
"installation class|BASE",
|
||||
"installation class|INSTALLATION",
|
||||
@@ -694,17 +720,24 @@
|
||||
"≥ {value} MW",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
],
|
||||
"ja": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"Acartia",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CC BY 4.0",
|
||||
"CCTV",
|
||||
"CH {index} / {total}",
|
||||
"CRS {value}°",
|
||||
@@ -729,9 +762,12 @@
|
||||
"Sentinel-2",
|
||||
"USB {rate} msg/s",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"dB",
|
||||
"ft",
|
||||
"hPa",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗",
|
||||
"km",
|
||||
"km/h",
|
||||
"kt",
|
||||
@@ -877,11 +913,19 @@
|
||||
"{value} KM/S",
|
||||
"ΔN",
|
||||
"▲ {frp} MW",
|
||||
"≥ {value} MW"
|
||||
"≥ {value} MW",
|
||||
"ANIMAL · {name}",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
],
|
||||
"ru": [
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"CCTV",
|
||||
"CRT",
|
||||
"FLIR",
|
||||
@@ -895,11 +939,16 @@
|
||||
"S2 GhostMaps",
|
||||
"S2 · {name}",
|
||||
"VS",
|
||||
"WHOT/BHOT"
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗"
|
||||
],
|
||||
"uk": [
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"CCTV",
|
||||
"CRT",
|
||||
"FLIR",
|
||||
@@ -908,7 +957,10 @@
|
||||
"NVG",
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
"WHOT/BHOT"
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗"
|
||||
],
|
||||
"zh-CN": [
|
||||
"1090 MHz",
|
||||
@@ -966,7 +1018,12 @@
|
||||
"≥ {value} MW",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
],
|
||||
"zh-Hant": [
|
||||
"1090 MHz",
|
||||
@@ -1024,6 +1081,11 @@
|
||||
"≥ {value} MW",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// The regional feed includes sightings outside the layer's Salish Sea coverage.
|
||||
export const ACARTIA_GUARD_BOX = Object.freeze({
|
||||
south: 45.5,
|
||||
west: -126,
|
||||
north: 50.5,
|
||||
east: -121,
|
||||
});
|
||||
|
||||
// Frozen, conservative cetacean species/alias list for this regional feed.
|
||||
// Exact normalized names only: an unfamiliar name must never become a whale
|
||||
// merely because it contains "whale", "dolphin" or a known species name.
|
||||
export const ACARTIA_CETACEAN_NAMES = Object.freeze([
|
||||
'Orcinus orca',
|
||||
'Orca',
|
||||
'Killer whale',
|
||||
'Southern resident killer whale',
|
||||
'Northern resident killer whale',
|
||||
'Resident killer whale',
|
||||
"Bigg's killer whale",
|
||||
'Transient killer whale',
|
||||
'Offshore killer whale',
|
||||
'Megaptera novaeangliae',
|
||||
'Humpback whale',
|
||||
'Humpback',
|
||||
'Eschrichtius robustus',
|
||||
'Gray whale',
|
||||
'Grey whale',
|
||||
'Balaenoptera acutorostrata',
|
||||
'Common minke whale',
|
||||
'Minke whale',
|
||||
'Balaenoptera physalus',
|
||||
'Fin whale',
|
||||
'Finback whale',
|
||||
'Balaenoptera musculus',
|
||||
'Blue whale',
|
||||
'Balaenoptera borealis',
|
||||
'Sei whale',
|
||||
'Eubalaena japonica',
|
||||
'North Pacific right whale',
|
||||
'Physeter macrocephalus',
|
||||
'Sperm whale',
|
||||
'Kogia breviceps',
|
||||
'Pygmy sperm whale',
|
||||
'Kogia sima',
|
||||
'Dwarf sperm whale',
|
||||
'Berardius bairdii',
|
||||
"Baird's beaked whale",
|
||||
'Ziphius cavirostris',
|
||||
"Cuvier's beaked whale",
|
||||
'Mesoplodon carlhubbsi',
|
||||
"Hubbs' beaked whale",
|
||||
'Mesoplodon stejnegeri',
|
||||
"Stejneger's beaked whale",
|
||||
'Mesoplodon ginkgodens',
|
||||
'Ginkgo-toothed beaked whale',
|
||||
'Phocoena phocoena',
|
||||
'Harbor porpoise',
|
||||
'Harbour porpoise',
|
||||
'Phocoenoides dalli',
|
||||
"Dall's porpoise",
|
||||
'Lagenorhynchus obliquidens',
|
||||
'Pacific white-sided dolphin',
|
||||
'Lissodelphis borealis',
|
||||
'Northern right whale dolphin',
|
||||
'Grampus griseus',
|
||||
"Risso's dolphin",
|
||||
'Delphinus delphis',
|
||||
'Short-beaked common dolphin',
|
||||
'Common dolphin',
|
||||
'Delphinus capensis',
|
||||
'Long-beaked common dolphin',
|
||||
'Tursiops truncatus',
|
||||
'Common bottlenose dolphin',
|
||||
'Bottlenose dolphin',
|
||||
'Delphinapterus leucas',
|
||||
'Beluga whale',
|
||||
'Beluga',
|
||||
'Pseudorca crassidens',
|
||||
'False killer whale',
|
||||
'Globicephala macrorhynchus',
|
||||
'Short-finned pilot whale',
|
||||
]);
|
||||
|
||||
// These names clearly identify a marine mammal but do not establish a species
|
||||
// in the cetacean list. Retain them for ALL/MAMMALS, never for WHALES.
|
||||
const OTHER_MARINE_MAMMAL_NAMES = Object.freeze([
|
||||
'Phoca vitulina',
|
||||
'Harbor seal',
|
||||
'Harbour seal',
|
||||
'Mirounga angustirostris',
|
||||
'Northern elephant seal',
|
||||
'Zalophus californianus',
|
||||
'California sea lion',
|
||||
'Eumetopias jubatus',
|
||||
'Steller sea lion',
|
||||
"Steller's sea lion",
|
||||
'Callorhinus ursinus',
|
||||
'Northern fur seal',
|
||||
'Enhydra lutris',
|
||||
'Sea otter',
|
||||
'Seal',
|
||||
'Unidentified seal',
|
||||
'Sea lion',
|
||||
'Unidentified sea lion',
|
||||
'Pinniped',
|
||||
'Unidentified pinniped',
|
||||
'Marine mammal',
|
||||
'Unidentified marine mammal',
|
||||
'Unknown marine mammal',
|
||||
'Cetacean',
|
||||
'Unidentified cetacean',
|
||||
'Unknown cetacean',
|
||||
'Whale',
|
||||
'Unidentified whale',
|
||||
'Unknown whale',
|
||||
'Dolphin',
|
||||
'Unidentified dolphin',
|
||||
'Unknown dolphin',
|
||||
'Porpoise',
|
||||
'Unidentified porpoise',
|
||||
'Unknown porpoise',
|
||||
]);
|
||||
|
||||
function speciesKey(value) {
|
||||
return typeof value === 'string'
|
||||
? value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\u2018\u2019]/g, "'")
|
||||
.replace(/[\u2010-\u2015]/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
: '';
|
||||
}
|
||||
|
||||
const CETACEANS = new Set(ACARTIA_CETACEAN_NAMES.map(speciesKey));
|
||||
const OTHER_MARINE_MAMMALS = new Set(OTHER_MARINE_MAMMAL_NAMES.map(speciesKey));
|
||||
|
||||
/** Classify exact normalized species names; ambiguous sightings stay noncetacean. */
|
||||
export function acartiaSpeciesGroup(value) {
|
||||
const key = speciesKey(value);
|
||||
if (CETACEANS.has(key)) return 'cetacean';
|
||||
return OTHER_MARINE_MAMMALS.has(key) ? 'marine-mammal' : null;
|
||||
}
|
||||
|
||||
/** Accept only positions inside the supported Salish Sea coverage. */
|
||||
export function inAcartiaCoverage(lat, lon) {
|
||||
return (
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lon) &&
|
||||
lat >= ACARTIA_GUARD_BOX.south &&
|
||||
lat <= ACARTIA_GUARD_BOX.north &&
|
||||
lon >= ACARTIA_GUARD_BOX.west &&
|
||||
lon <= ACARTIA_GUARD_BOX.east
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ACARTIA_CETACEAN_NAMES, acartiaSpeciesGroup } from './acartiaTaxa.js';
|
||||
|
||||
test('frozen cetacean names accept only normalized exact species or aliases', () => {
|
||||
assert.equal(Object.isFrozen(ACARTIA_CETACEAN_NAMES), true);
|
||||
assert.throws(() => ACARTIA_CETACEAN_NAMES.push('Whale shark'), TypeError);
|
||||
for (const name of ACARTIA_CETACEAN_NAMES) {
|
||||
assert.equal(acartiaSpeciesGroup(name), 'cetacean');
|
||||
assert.equal(acartiaSpeciesGroup(` ${name.toUpperCase()} `), 'cetacean');
|
||||
assert.equal(acartiaSpeciesGroup(`${name} or another animal`), null);
|
||||
}
|
||||
assert.equal(acartiaSpeciesGroup('Dall’s porpoise'), 'cetacean');
|
||||
assert.equal(acartiaSpeciesGroup('Pacific white–sided dolphin'), 'cetacean');
|
||||
assert.equal(acartiaSpeciesGroup(' HARBOR PORPOISE '), 'cetacean');
|
||||
});
|
||||
|
||||
test('clearly marine mammal names remain distinct from validated cetacean species', () => {
|
||||
for (const name of [
|
||||
'Harbor seal',
|
||||
'Phoca vitulina',
|
||||
'Sea otter',
|
||||
'Unidentified whale',
|
||||
]) {
|
||||
assert.equal(acartiaSpeciesGroup(name), 'marine-mammal');
|
||||
}
|
||||
for (const name of [
|
||||
'Unknown',
|
||||
'Whale shark',
|
||||
'Elephant',
|
||||
'',
|
||||
null,
|
||||
{ species: 'Orca' },
|
||||
]) {
|
||||
assert.equal(acartiaSpeciesGroup(name), null);
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,114 @@
|
||||
import { drainWildlifeWork, WILDLIFE_WORK_UNIT } from './performance.js';
|
||||
import { wildlifeIconicGroup } from './model.js';
|
||||
|
||||
/** Incremental billboard reconciliation, independent of Cesium and the DOM.
|
||||
* A refresh changes only added/removed contacts and properties that differ.
|
||||
* Position sampling and appearance construction run only for changed rows. */
|
||||
export function createWildlifeBillboardModel({
|
||||
collection,
|
||||
pointById,
|
||||
positions,
|
||||
positionForRow,
|
||||
appearanceForGroup,
|
||||
}) {
|
||||
const previous = new Map();
|
||||
|
||||
function update(rows, options = {}) {
|
||||
return drainWildlifeWork(updateWork(rows, options));
|
||||
}
|
||||
|
||||
function* updateWork(rows, { onOperations = null } = {}) {
|
||||
const nextIds = new Set();
|
||||
const result = { created: 0, removed: 0, updated: 0, writes: 0 };
|
||||
let visits = 0;
|
||||
for (const row of rows) {
|
||||
if (visits++ % WILDLIFE_WORK_UNIT === 0) yield 'billboard-update';
|
||||
const id = row.stableId;
|
||||
if (nextIds.has(id)) continue;
|
||||
nextIds.add(id);
|
||||
const group = wildlifeIconicGroup(row.iconic);
|
||||
const old = previous.get(id);
|
||||
const point = pointById.get(id);
|
||||
if (
|
||||
old &&
|
||||
point &&
|
||||
old.lat === row.lat &&
|
||||
old.lon === row.lon &&
|
||||
old.group === group
|
||||
)
|
||||
continue;
|
||||
const appearance =
|
||||
old && old.group === group ? old.appearance : appearanceForGroup(group);
|
||||
const positionChanged =
|
||||
!old || old.lat !== row.lat || old.lon !== row.lon;
|
||||
const position = positionChanged
|
||||
? positionForRow(row)
|
||||
: positions.get(id);
|
||||
if (!point) {
|
||||
const options = { id: `wildlife:${id}`, position, ...appearance };
|
||||
pointById.set(id, collection.add(options));
|
||||
positions.set(id, position);
|
||||
result.created += 1;
|
||||
result.writes += Object.keys(options).length;
|
||||
} else {
|
||||
let writes = 0;
|
||||
if (positionChanged) {
|
||||
point.position = position;
|
||||
positions.set(id, position);
|
||||
writes += 1;
|
||||
}
|
||||
for (const key of Object.keys(appearance)) {
|
||||
// Cesium clones Color and NearFarScalar values on assignment. Our
|
||||
// last descriptor, rather than getter reference identity, records
|
||||
// whether the appearance has actually changed.
|
||||
if (old?.appearance?.[key] === appearance[key]) continue;
|
||||
point[key] = appearance[key];
|
||||
writes += 1;
|
||||
}
|
||||
result.writes += writes;
|
||||
if (writes) result.updated += 1;
|
||||
}
|
||||
previous.set(id, {
|
||||
lat: row.lat,
|
||||
lon: row.lon,
|
||||
group,
|
||||
appearance,
|
||||
});
|
||||
}
|
||||
for (const [id, point] of pointById) {
|
||||
if (visits++ % WILDLIFE_WORK_UNIT === 0) yield 'billboard-update';
|
||||
if (nextIds.has(id)) continue;
|
||||
collection.remove(point);
|
||||
pointById.delete(id);
|
||||
positions.delete(id);
|
||||
previous.delete(id);
|
||||
result.removed += 1;
|
||||
}
|
||||
onOperations?.({ billboardVisits: visits, billboardWrites: result.writes });
|
||||
return result;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
collection.removeAll();
|
||||
pointById.clear();
|
||||
positions.clear();
|
||||
previous.clear();
|
||||
}
|
||||
|
||||
// Font readiness replaces just the cached texture. Never resample anchors,
|
||||
// clear the collection or allocate replacement billboards for this swap.
|
||||
function refreshImages(imageForGroup) {
|
||||
let writes = 0;
|
||||
for (const [id, old] of previous) {
|
||||
const point = pointById.get(id);
|
||||
const image = imageForGroup(old.group);
|
||||
if (!point || old.appearance.image === image) continue;
|
||||
point.image = image;
|
||||
old.appearance = { ...old.appearance, image };
|
||||
writes += 1;
|
||||
}
|
||||
return writes;
|
||||
}
|
||||
|
||||
return { update, updateWork, clear, refreshImages };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Shared by browser viewport queries and the provider's cache-key validation.
|
||||
export const WILDLIFE_MAX_VIEW_DEGREES = 40;
|
||||
|
||||
const cleanDegree = (value) => Math.round(value * 1e8) / 1e8;
|
||||
const spanOf = (box) => Math.max(box.north - box.south, box.east - box.west);
|
||||
|
||||
/** Snap non-crossing boxes outward to one canonical grid. Validate the final
|
||||
* span too: a legal raw box may expand past the supported query size. */
|
||||
export function snapWildlifeBox(box) {
|
||||
if (
|
||||
!box ||
|
||||
!['south', 'west', 'north', 'east'].every((key) =>
|
||||
Number.isFinite(box[key]),
|
||||
) ||
|
||||
box.south < -90 ||
|
||||
box.north > 90 ||
|
||||
box.west < -180 ||
|
||||
box.east > 180 ||
|
||||
box.north <= box.south ||
|
||||
box.east <= box.west ||
|
||||
spanOf(box) > WILDLIFE_MAX_VIEW_DEGREES + 1e-8
|
||||
)
|
||||
return null;
|
||||
let grid = spanOf(box) > 5 ? 1 : 0.1;
|
||||
const snap = () => ({
|
||||
south: Math.max(
|
||||
-90,
|
||||
cleanDegree(Math.floor((box.south + 1e-10) / grid) * grid),
|
||||
),
|
||||
west: Math.max(
|
||||
-180,
|
||||
cleanDegree(Math.floor((box.west + 1e-10) / grid) * grid),
|
||||
),
|
||||
north: Math.min(
|
||||
90,
|
||||
cleanDegree(Math.ceil((box.north - 1e-10) / grid) * grid),
|
||||
),
|
||||
east: Math.min(
|
||||
180,
|
||||
cleanDegree(Math.ceil((box.east - 1e-10) / grid) * grid),
|
||||
),
|
||||
});
|
||||
let snapped = snap();
|
||||
// Crossing the five-degree boundary while snapping must pick the same grid
|
||||
// on the browser and proxy, and on every subsequent snap of this box.
|
||||
if (grid === 0.1 && spanOf(snapped) > 5 + 1e-8) {
|
||||
grid = 1;
|
||||
snapped = snap();
|
||||
}
|
||||
return spanOf(snapped) <= WILDLIFE_MAX_VIEW_DEGREES ? snapped : null;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { inaturalistProxy } from '../../../server/providers/inaturalist/observations.js';
|
||||
import { syntheticGrid } from '../../../server/providers/inaturalist/grid.fixture.mjs';
|
||||
import {
|
||||
createWildlifeSightingsSource,
|
||||
WILDLIFE_GRID_TTL_MS,
|
||||
} from './source.js';
|
||||
|
||||
// Exercise both real cache owners through their projected response contract.
|
||||
// Only the HTTP transport and upstream data are synthetic; no socket is needed.
|
||||
test('client and proxy share stale revalidation and publish its fresh result in the same view', async () => {
|
||||
let now = Date.parse('2026-11-04T18:05:00Z');
|
||||
let upstreamCalls = 0;
|
||||
let proxyCalls = 0;
|
||||
let finishRefresh;
|
||||
let handler;
|
||||
inaturalistProxy({
|
||||
now: () => now,
|
||||
cacheDir: null,
|
||||
queueOptions: { intervalMs: 0, windowMs: 0 },
|
||||
warn() {},
|
||||
fetchImpl() {
|
||||
upstreamCalls++;
|
||||
return upstreamCalls === 1
|
||||
? Response.json(syntheticGrid())
|
||||
: new Promise((resolve) => {
|
||||
finishRefresh = resolve;
|
||||
});
|
||||
},
|
||||
}).configureServer({
|
||||
middlewares: {
|
||||
use(_mount, middleware) {
|
||||
handler = middleware;
|
||||
},
|
||||
},
|
||||
});
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
async fetchImpl(url) {
|
||||
proxyCalls++;
|
||||
let status;
|
||||
let headers;
|
||||
let response;
|
||||
await handler(
|
||||
{
|
||||
method: 'GET',
|
||||
url: url.slice('/api/inaturalist'.length),
|
||||
headers: {},
|
||||
socket: { remoteAddress: 'synthetic' },
|
||||
},
|
||||
{
|
||||
writeHead(code, values) {
|
||||
status = code;
|
||||
headers = values;
|
||||
},
|
||||
end(body) {
|
||||
response = new Response(body, { status, headers });
|
||||
},
|
||||
},
|
||||
);
|
||||
return response;
|
||||
},
|
||||
});
|
||||
const options = { tiles: [{ z: 2, x: 1, y: 1 }], taxa: 'Aves' };
|
||||
const first = await source.getGridSnapshot(options);
|
||||
assert.equal(first.cells.length, 2);
|
||||
await source.getGridSnapshot(options);
|
||||
assert.equal(upstreamCalls, 1);
|
||||
assert.equal(proxyCalls, 1, 'client hits do not even call the proxy');
|
||||
now += WILDLIFE_GRID_TTL_MS;
|
||||
const publications = [];
|
||||
let settled = false;
|
||||
const refreshing = source
|
||||
.getGridSnapshot({
|
||||
...options,
|
||||
onTile: (value) => publications.push(value),
|
||||
})
|
||||
.then((value) => {
|
||||
settled = true;
|
||||
return value;
|
||||
});
|
||||
try {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(settled, false, 'revalidation is still waiting for upstream');
|
||||
assert.equal(
|
||||
publications[0]?.stale,
|
||||
true,
|
||||
'old cells already reached the renderer',
|
||||
);
|
||||
assert.deepEqual(publications[0].cells, first.cells);
|
||||
assert.equal(
|
||||
upstreamCalls,
|
||||
2,
|
||||
'normal stale GET and refresh=wait share one upstream attempt',
|
||||
);
|
||||
finishRefresh(Response.json(syntheticGrid()));
|
||||
const fresh = await refreshing;
|
||||
assert.equal(fresh.stale, false);
|
||||
assert.equal(fresh.fetchedAt, now);
|
||||
assert.equal(publications.at(-1).fetchedAt, now);
|
||||
assert.notEqual(publications.at(-1).stale, true);
|
||||
const before = proxyCalls;
|
||||
await source.getGridSnapshot(options);
|
||||
assert.equal(proxyCalls, before);
|
||||
assert.equal(upstreamCalls, 2);
|
||||
} finally {
|
||||
finishRefresh?.(Response.json(syntheticGrid()));
|
||||
await refreshing;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { photoCreator } from './photoText.js';
|
||||
import { getLocale, t, translateKnown } from '../../i18n/index.js';
|
||||
import { CCTV_THUMBNAIL_STYLE } from '../../overlays/worldOverlayTokens.js';
|
||||
import { truncateOverlayTitle } from '../../overlays/worldOverlayDraw.js';
|
||||
import { isOpenPhoto } from './records.js';
|
||||
import { compareWildlifeIds, wildlifeIconicGroup } from './model.js';
|
||||
import {
|
||||
ICONIC_TINTS,
|
||||
WILDLIFE_CARD_LIMIT,
|
||||
WILDLIFE_CARD_MIN_SEP_PX,
|
||||
WILDLIFE_CARD_REGION_COLUMNS,
|
||||
WILDLIFE_CARD_REGION_ROWS,
|
||||
WILDLIFE_CARD_ALTITUDE_SCALE,
|
||||
WILDLIFE_CARD_FADE_LOW_METERS,
|
||||
WILDLIFE_CARD_FADE_HIGH_METERS,
|
||||
WILDLIFE_SELECTED_LABEL_FADE_LOW_METERS,
|
||||
WILDLIFE_SELECTED_LABEL_FADE_HIGH_METERS,
|
||||
WILDLIFE_SELECTED_LABEL_MAX_CHARS,
|
||||
WILDLIFE_CARD_MAX_DISTANCE_METERS,
|
||||
WILDLIFE_LABEL_LIMIT,
|
||||
} from './policy.js';
|
||||
|
||||
export const WILDLIFE_OVERLAY_SOURCE_ID = 'wildlife-sightings';
|
||||
export const WILDLIFE_LABEL_SOURCE_ID = 'wildlife-sightings-labels';
|
||||
export const WILDLIFE_SELECTED_SOURCE_ID = 'wildlife-sightings-selected';
|
||||
export const WILDLIFE_LABEL_SOURCE_OPTIONS = Object.freeze({
|
||||
cohortLimit: WILDLIFE_LABEL_LIMIT,
|
||||
collisionCapacity: WILDLIFE_LABEL_LIMIT,
|
||||
moving: false,
|
||||
});
|
||||
export const WILDLIFE_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
cohortLimit: WILDLIFE_CARD_LIMIT,
|
||||
collisionCapacity: WILDLIFE_CARD_LIMIT,
|
||||
moving: false,
|
||||
});
|
||||
export const WILDLIFE_SELECTED_SOURCE_OPTIONS = Object.freeze({
|
||||
cohortLimit: 1,
|
||||
collisionCapacity: 1,
|
||||
moving: false,
|
||||
});
|
||||
|
||||
/** Match cards and labels to the sighting sprite's taxon tint. */
|
||||
export function wildlifeAccent(iconic) {
|
||||
return ICONIC_TINTS[wildlifeIconicGroup(iconic)];
|
||||
}
|
||||
|
||||
/** Importance score for open, non-sensitive iNaturalist photos. Other rows
|
||||
* cannot consume a slot, even if an accidental photo field reaches a caller. */
|
||||
export function ambientScore(row, nowMs) {
|
||||
if (row?.obscured || !isOpenPhoto(row?.photo) || !photoCreator(row.photo))
|
||||
return -Infinity;
|
||||
const nonnegative = (n) => (Number.isFinite(n) ? Math.max(0, n) : 0);
|
||||
const time = Number.isFinite(row.observedAt)
|
||||
? row.observedAt
|
||||
: Date.parse(row.observedOn);
|
||||
const ageH =
|
||||
Number.isFinite(time) && Number.isFinite(nowMs)
|
||||
? Math.max(0, (nowMs - time) / 3_600_000)
|
||||
: Infinity;
|
||||
return (
|
||||
(row.quality === 'research' ? 2 : 0) +
|
||||
(row.threatened ? 1 : 0) +
|
||||
Math.log1p(nonnegative(row.favesCount)) +
|
||||
0.5 * Math.log1p(nonnegative(row.identificationAgreements)) +
|
||||
2 * 0.5 ** (ageH / 12)
|
||||
);
|
||||
}
|
||||
|
||||
/** Named rows without eligible photos rank below eligible photo rows. */
|
||||
export function wildlifeLabelScore(row, nowMs) {
|
||||
const score = ambientScore(row, nowMs);
|
||||
return Number.isFinite(score) ? score : -1;
|
||||
}
|
||||
|
||||
/** Visible incumbents own their slots until they leave the view/window. A new
|
||||
* random sample or a higher-scoring challenger can only fill a free slot. */
|
||||
function rankWildlifeAmbientRows(rows, currentIds, nowMs) {
|
||||
const incumbent = new Map(currentIds.map((id, index) => [id, index]));
|
||||
const scored = rows
|
||||
.map((row) => ({
|
||||
row,
|
||||
score: ambientScore(row, nowMs),
|
||||
current: incumbent.get(row.stableId),
|
||||
}))
|
||||
.filter((item) => Number.isFinite(item.score));
|
||||
scored.sort((a, b) => {
|
||||
if (a.current !== undefined || b.current !== undefined) {
|
||||
if (a.current === undefined) return 1;
|
||||
if (b.current === undefined) return -1;
|
||||
return a.current - b.current;
|
||||
}
|
||||
return (
|
||||
b.score - a.score || compareWildlifeIds(a.row.stableId, b.row.stableId)
|
||||
);
|
||||
});
|
||||
return scored.map((item) => item.row);
|
||||
}
|
||||
|
||||
/** Spread newly admitted cards over screen regions. Once shown, a card stays
|
||||
* even when a pan moves it into another incumbent's region; region quotas only
|
||||
* govern free slots, so a camera movement never replaces a visible photo. */
|
||||
export function selectWildlifeAmbientRows(
|
||||
rows,
|
||||
currentIds = [],
|
||||
nowMs,
|
||||
limit = WILDLIFE_CARD_LIMIT,
|
||||
{ screens = null, width = 0, height = 0 } = {},
|
||||
) {
|
||||
const incumbent = new Set(currentIds);
|
||||
const accepted = [];
|
||||
const regions = new Set();
|
||||
const regionFor = (screen) =>
|
||||
`${Math.min(WILDLIFE_CARD_REGION_COLUMNS - 1, Math.floor((screen.x / width) * WILDLIFE_CARD_REGION_COLUMNS))}:${Math.min(WILDLIFE_CARD_REGION_ROWS - 1, Math.floor((screen.y / height) * WILDLIFE_CARD_REGION_ROWS))}`;
|
||||
for (const row of rankWildlifeAmbientRows(rows, currentIds, nowMs)) {
|
||||
if (accepted.length >= Math.max(0, limit)) break;
|
||||
const screen = screens?.get(row.stableId);
|
||||
const region = screen && width > 0 && height > 0 ? regionFor(screen) : null;
|
||||
if (!incumbent.has(row.stableId)) {
|
||||
if (region !== null && regions.has(region)) continue;
|
||||
if (
|
||||
screen &&
|
||||
accepted.some((other) => {
|
||||
const prior = screens.get(other.stableId);
|
||||
return (
|
||||
prior &&
|
||||
Math.hypot(prior.x - screen.x, prior.y - screen.y) <
|
||||
WILDLIFE_CARD_MIN_SEP_PX
|
||||
);
|
||||
})
|
||||
)
|
||||
continue;
|
||||
}
|
||||
accepted.push(row);
|
||||
if (region !== null) regions.add(region);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function nameFor(row) {
|
||||
return translateKnown(row.commonName || row.scientificName) || t('SIGHTING');
|
||||
}
|
||||
|
||||
/** Return a trimmed common name, or null for unnamed observations. */
|
||||
export function wildlifeCommonName(row) {
|
||||
const name = row.commonName;
|
||||
return typeof name === 'string' && name.trim() ? name.trim() : null;
|
||||
}
|
||||
|
||||
/** Full translated display name, shared by titles and screen-space deduping. */
|
||||
export function wildlifeDisplayName(row) {
|
||||
const commonName = wildlifeCommonName(row);
|
||||
const name = commonName ? translateKnown(commonName).trim() : '';
|
||||
return name.replace(/\s+/g, ' ').trim().toLocaleUpperCase(getLocale());
|
||||
}
|
||||
|
||||
function compactName(row) {
|
||||
return truncateOverlayTitle(
|
||||
wildlifeDisplayName(row),
|
||||
WILDLIFE_SELECTED_LABEL_MAX_CHARS,
|
||||
);
|
||||
}
|
||||
|
||||
/** One-line common names for identified observations. The caller adds the
|
||||
* Cartesian anchor after ranking and front-side/screen-space filtering. */
|
||||
export function buildWildlifeLabelEntry(
|
||||
row,
|
||||
{ select, hasRow, nowMs = Date.now() } = {},
|
||||
) {
|
||||
if (!wildlifeCommonName(row)) return null;
|
||||
return {
|
||||
id: `wildlife-sightings-label:${row.stableId}`,
|
||||
variant: 'label',
|
||||
title: compactName(row),
|
||||
accent: wildlifeAccent(row.iconic),
|
||||
priority: wildlifeLabelScore(row, nowMs),
|
||||
collisionGroup: 'ambient-label',
|
||||
// The host checks actual card placements, including sticky/fading cards,
|
||||
// before spending the remaining space on labels in score order.
|
||||
yieldToCollisionGroup: 'ambient-card',
|
||||
stateless: true,
|
||||
paintLane: 'ambient-label',
|
||||
gapPx: 15,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
horizonCull: true,
|
||||
edgeFade: 'keyhole',
|
||||
interactive: false,
|
||||
accessibilityLabel: nameFor(row),
|
||||
...selectionAction(row, { select, hasRow }),
|
||||
};
|
||||
}
|
||||
|
||||
function selectionAction(row, { select, hasRow } = {}) {
|
||||
if (typeof select !== 'function') return {};
|
||||
return {
|
||||
interactive: true,
|
||||
accessibilityLabel: t('Show {name} sighting', { name: nameFor(row) }),
|
||||
activate() {
|
||||
if (typeof hasRow === 'function' && !hasRow(row.stableId)) return false;
|
||||
return select(row.stableId) !== false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an attributed ambient photo; the runtime supplies the decoded image. */
|
||||
export function buildWildlifeThumbnailEntry(
|
||||
row,
|
||||
nowMs,
|
||||
{ image = null, select, hasRow, selected = false } = {},
|
||||
) {
|
||||
const score = ambientScore(row, nowMs);
|
||||
if (!Number.isFinite(score)) return null;
|
||||
return {
|
||||
id: `sighting:${row.stableId}`,
|
||||
variant: 'thumbnail',
|
||||
title: nameFor(row),
|
||||
image,
|
||||
requireImage: true,
|
||||
accent: wildlifeAccent(row.iconic),
|
||||
interactive: false,
|
||||
accessibilityLabel: t('Show {name} sighting', { name: nameFor(row) }),
|
||||
collisionGroup: 'ambient-card',
|
||||
horizonCull: true,
|
||||
edgeFade: 'keyhole',
|
||||
verticalOnly: true,
|
||||
gapPx: 15,
|
||||
thumbnailPadX: CCTV_THUMBNAIL_STYLE.padding,
|
||||
thumbnailPadTop: CCTV_THUMBNAIL_STYLE.padding,
|
||||
thumbnailPadBottom: CCTV_THUMBNAIL_STYLE.padding,
|
||||
thumbnailTitleGap: 2,
|
||||
thumbnailTitleHeight: CCTV_THUMBNAIL_STYLE.titleHeight,
|
||||
thumbnailTitleChars: CCTV_THUMBNAIL_STYLE.titleChars,
|
||||
thumbnailTitleWordBoundary: true,
|
||||
thumbnailBackground: CCTV_THUMBNAIL_STYLE.background,
|
||||
thumbnailTitleColor: CCTV_THUMBNAIL_STYLE.titleColor,
|
||||
thumbnailTitleFont: CCTV_THUMBNAIL_STYLE.titleFont,
|
||||
thumbnailLeaderColor: CCTV_THUMBNAIL_STYLE.leader,
|
||||
thumbnailRuleColor: CCTV_THUMBNAIL_STYLE.rule,
|
||||
thumbnailRuleHeight: CCTV_THUMBNAIL_STYLE.ruleHeight,
|
||||
thumbnailRadius: CCTV_THUMBNAIL_STYLE.radius,
|
||||
...selectionAction(row, { select, hasRow }),
|
||||
// i18n-ignore: the creator is verbatim feed attribution with a copyright mark
|
||||
details: [`© ${photoCreator(row.photo)}`],
|
||||
thumbnailFixedWidth: true,
|
||||
thumbnailFit: 'contain',
|
||||
thumbnailWidth: 96,
|
||||
thumbnailHeight: 72,
|
||||
priority: selected ? Number.MAX_SAFE_INTEGER : score,
|
||||
minAnchorSeparationPx: WILDLIFE_CARD_MIN_SEP_PX,
|
||||
maxDistance: WILDLIFE_CARD_MAX_DISTANCE_METERS,
|
||||
altitudeScale: WILDLIFE_CARD_ALTITUDE_SCALE,
|
||||
altitudeFadeStart: WILDLIFE_CARD_FADE_LOW_METERS,
|
||||
altitudeFadeEnd: WILDLIFE_CARD_FADE_HIGH_METERS,
|
||||
...(selected ? { selected: true, paintLane: 'selected' } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A bounded selected name uses the host's shared selected card treatment. */
|
||||
export function buildWildlifeSelectedLabel(row) {
|
||||
if (!wildlifeCommonName(row)) return null;
|
||||
return {
|
||||
id: `wildlife-sightings-label:${row.stableId}`,
|
||||
variant: 'label',
|
||||
selected: true,
|
||||
protected: true,
|
||||
yieldToCollisionGroup: 'ambient-card',
|
||||
paintLane: 'selected',
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
title: compactName(row),
|
||||
accent: wildlifeAccent(row.iconic),
|
||||
gapPx: 15,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
horizonCull: true,
|
||||
edgeFade: 'keyhole',
|
||||
interactive: false,
|
||||
accessibilityLabel: nameFor(row),
|
||||
altitudeFadeStart: WILDLIFE_SELECTED_LABEL_FADE_LOW_METERS,
|
||||
altitudeFadeEnd: WILDLIFE_SELECTED_LABEL_FADE_HIGH_METERS,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ambientScore,
|
||||
selectWildlifeAmbientRows,
|
||||
buildWildlifeThumbnailEntry,
|
||||
buildWildlifeLabelEntry,
|
||||
buildWildlifeSelectedLabel,
|
||||
wildlifeAccent,
|
||||
WILDLIFE_OVERLAY_SOURCE_OPTIONS,
|
||||
WILDLIFE_LABEL_SOURCE_ID,
|
||||
WILDLIFE_LABEL_SOURCE_OPTIONS,
|
||||
WILDLIFE_SELECTED_SOURCE_OPTIONS,
|
||||
} from './cards.js';
|
||||
import { CCTV_THUMBNAIL_STYLE } from '../../overlays/worldOverlayTokens.js';
|
||||
import {
|
||||
WILDLIFE_SELECTED_LABEL_MAX_CHARS,
|
||||
WILDLIFE_CARD_SHOW_MAX_METERS,
|
||||
WILDLIFE_CARD_HIDE_MAX_METERS,
|
||||
WILDLIFE_LABEL_LIMIT,
|
||||
} from './policy.js';
|
||||
import { formatAge } from './photoText.js';
|
||||
import { createSelection } from './selection.js';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import {
|
||||
altitudeFade,
|
||||
altitudeScale,
|
||||
measureOverlayEntry,
|
||||
} from '../../overlays/worldOverlayDraw.js';
|
||||
|
||||
const now = Date.UTC(2026, 10, 4, 18, 40);
|
||||
const hour = 3_600_000;
|
||||
const photo = {
|
||||
square:
|
||||
'https://inaturalist-open-data.s3.amazonaws.com/photos/123/square.jpeg',
|
||||
medium:
|
||||
'https://inaturalist-open-data.s3.amazonaws.com/photos/123/medium.jpeg',
|
||||
licence: 'cc-by-nc',
|
||||
attribution: '(c) Synthetic Photographer, some rights reserved',
|
||||
};
|
||||
const row = (overrides = {}) => ({
|
||||
stableId: 'inat:123',
|
||||
origin: 'inaturalist',
|
||||
lat: 47.6,
|
||||
lon: -122.3,
|
||||
observedAt: now - 2 * hour,
|
||||
observedOn: '2026-11-04',
|
||||
obscured: false,
|
||||
cell: null,
|
||||
quality: 'research',
|
||||
accuracyM: 12,
|
||||
commonName: "Anna's Hummingbird",
|
||||
scientificName: 'Calypte anna',
|
||||
iconic: 'Aves',
|
||||
threatened: false,
|
||||
conservation: null,
|
||||
count: null,
|
||||
provider: null,
|
||||
obsLicence: 'cc-by-nc',
|
||||
link: 'https://www.inaturalist.org/observations/123',
|
||||
photo,
|
||||
favesCount: 0,
|
||||
identificationAgreements: 0,
|
||||
...overrides,
|
||||
});
|
||||
const acartia = (overrides = {}) =>
|
||||
row({
|
||||
stableId: 'acartia:abc',
|
||||
origin: 'acartia',
|
||||
iconic: 'Mammalia',
|
||||
commonName: 'Orca',
|
||||
scientificName: null,
|
||||
accuracyM: null,
|
||||
count: 5,
|
||||
provider: { name: 'Synthetic Network', entity: 'Whale Alert' },
|
||||
obsLicence: null,
|
||||
photo: null,
|
||||
link: 'https://acartia.io',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('ambient photo score prioritizes grade, threat, appreciation and recency exactly', () => {
|
||||
const base = row({ quality: 'needs_id' });
|
||||
assert.ok(
|
||||
Math.abs(ambientScore(row(), now) - ambientScore(base, now) - 2) < 1e-12,
|
||||
);
|
||||
assert.equal(
|
||||
ambientScore(row({ threatened: true, observedAt: now }), now) -
|
||||
ambientScore(row({ observedAt: now }), now),
|
||||
1,
|
||||
);
|
||||
assert.ok(
|
||||
ambientScore(row({ favesCount: 8 }), now) >
|
||||
ambientScore(row({ favesCount: 1 }), now),
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
ambientScore(row({ identificationAgreements: 3 }), now) -
|
||||
ambientScore(row(), now) -
|
||||
0.5 * Math.log1p(3),
|
||||
) < 1e-12,
|
||||
);
|
||||
assert.ok(
|
||||
ambientScore(row({ quality: 'needs_id', observedAt: now - hour }), now) >
|
||||
ambientScore(
|
||||
row({ quality: 'needs_id', observedAt: now - 48 * hour }),
|
||||
now,
|
||||
),
|
||||
);
|
||||
for (const input of [
|
||||
row({ obscured: true }),
|
||||
row({ photo: null }),
|
||||
row({ photo: { ...photo, licence: 'cc-by-nd' } }),
|
||||
])
|
||||
assert.equal(ambientScore(input, now), -Infinity);
|
||||
});
|
||||
|
||||
test('ambient ranking ties by stable id and never replaces a visible incumbent', () => {
|
||||
const a = row({ stableId: 'inat:a' });
|
||||
const b = row({ stableId: 'inat:b' });
|
||||
assert.deepEqual(
|
||||
selectWildlifeAmbientRows([b, a], [], now).map((item) => item.stableId),
|
||||
['inat:a', 'inat:b'],
|
||||
);
|
||||
const slightlyBetter = row({ stableId: 'inat:b', observedAt: now - hour });
|
||||
assert.ok(ambientScore(slightlyBetter, now) - ambientScore(a, now) < 0.5);
|
||||
assert.equal(
|
||||
selectWildlifeAmbientRows([a, slightlyBetter], ['inat:a'], now, 1)[0]
|
||||
.stableId,
|
||||
'inat:a',
|
||||
);
|
||||
assert.equal(
|
||||
selectWildlifeAmbientRows(
|
||||
[a, row({ stableId: 'inat:b', threatened: true })],
|
||||
['inat:a'],
|
||||
now,
|
||||
1,
|
||||
)[0].stableId,
|
||||
'inat:a',
|
||||
);
|
||||
assert.equal(
|
||||
selectWildlifeAmbientRows([a], ['gone'], now, 24)[0].stableId,
|
||||
a.stableId,
|
||||
);
|
||||
});
|
||||
|
||||
test('ambient entries are stable, accessible and select in place until their row is gone', () => {
|
||||
const selected = [];
|
||||
let exists = true;
|
||||
const entry = buildWildlifeThumbnailEntry(row(), now, {
|
||||
select(id) {
|
||||
selected.push(id);
|
||||
},
|
||||
hasRow: () => exists,
|
||||
});
|
||||
assert.equal(entry.id, 'sighting:inat:123');
|
||||
assert.equal(entry.interactive, true);
|
||||
assert.equal(entry.accessibilityLabel, "Show Anna's Hummingbird sighting");
|
||||
assert.equal(entry.activate(), true);
|
||||
assert.deepEqual(selected, ['inat:123']);
|
||||
exists = false;
|
||||
assert.equal(entry.activate(), false);
|
||||
assert.equal(selected.length, 1);
|
||||
assert.equal(buildWildlifeThumbnailEntry(row({ obscured: true }), now), null);
|
||||
});
|
||||
|
||||
test('all wildlife name labels prefer a complete word before their ellipsis and retain the full accessible name', () => {
|
||||
const commonName =
|
||||
'Great Blue Heron With An Unusually Lengthy Synthetic Common Name';
|
||||
for (const build of [buildWildlifeLabelEntry, buildWildlifeSelectedLabel]) {
|
||||
const entry = build(row({ commonName }));
|
||||
assert.equal(entry.title, 'GREAT BLUE HERON WITH AN UNUSUALLY…');
|
||||
assert.ok(
|
||||
Array.from(entry.title).length <= WILDLIFE_SELECTED_LABEL_MAX_CHARS,
|
||||
);
|
||||
assert.ok(entry.accessibilityLabel.includes(commonName));
|
||||
const unbroken = build(row({ commonName: '𓅃'.repeat(60) }));
|
||||
assert.equal(
|
||||
Array.from(unbroken.title).length,
|
||||
WILDLIFE_SELECTED_LABEL_MAX_CHARS,
|
||||
);
|
||||
assert.ok(unbroken.title.endsWith('…'));
|
||||
assert.ok(!unbroken.title.includes('\ufffd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('thumbnail entries use every shared thumbnail token and wildlife altitude curves', () => {
|
||||
const entry = buildWildlifeThumbnailEntry(row(), now);
|
||||
assert.deepEqual([entry.thumbnailWidth, entry.thumbnailHeight], [96, 72]);
|
||||
for (const key of ['thumbnailPadX', 'thumbnailPadTop', 'thumbnailPadBottom'])
|
||||
assert.equal(entry[key], CCTV_THUMBNAIL_STYLE.padding);
|
||||
const mapping = {
|
||||
thumbnailTitleHeight: 'titleHeight',
|
||||
thumbnailTitleChars: 'titleChars',
|
||||
thumbnailBackground: 'background',
|
||||
thumbnailTitleColor: 'titleColor',
|
||||
thumbnailTitleFont: 'titleFont',
|
||||
thumbnailLeaderColor: 'leader',
|
||||
thumbnailRuleColor: 'rule',
|
||||
thumbnailRuleHeight: 'ruleHeight',
|
||||
thumbnailRadius: 'radius',
|
||||
};
|
||||
for (const [field, token] of Object.entries(mapping))
|
||||
assert.equal(entry[field], CCTV_THUMBNAIL_STYLE[token]);
|
||||
assert.equal(entry.altitudeScale.fullEnd, 20_000);
|
||||
assert.equal(entry.altitudeScale.midEnd, 120_000);
|
||||
assert.equal(entry.altitudeFadeStart, 70_000);
|
||||
assert.equal(entry.altitudeFadeEnd, 125_000);
|
||||
assert.equal(entry.maxDistance, 600_000);
|
||||
assert.equal(entry.minAnchorSeparationPx, 112);
|
||||
assert.equal(entry.accent, wildlifeAccent('Aves'));
|
||||
assert.equal(entry.horizonCull, true);
|
||||
assert.equal(entry.requireImage, true);
|
||||
assert.deepEqual(entry.details, ['© Synthetic Photographer']);
|
||||
assert.equal(entry.thumbnailFixedWidth, true);
|
||||
assert.equal(entry.thumbnailFit, 'contain');
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(entry)), entry);
|
||||
assert.equal(entry.interactive, false);
|
||||
assert.equal(Object.isFrozen(WILDLIFE_OVERLAY_SOURCE_OPTIONS), true);
|
||||
assert.equal(WILDLIFE_OVERLAY_SOURCE_OPTIONS.cohortLimit, 24);
|
||||
assert.equal(WILDLIFE_SELECTED_SOURCE_OPTIONS.cohortLimit, 1);
|
||||
assert.equal(WILDLIFE_SELECTED_SOURCE_OPTIONS.collisionCapacity, 1);
|
||||
});
|
||||
|
||||
test('ambient cards retain only the credited creator and leave names for host truncation', () => {
|
||||
const commonName = 'An extremely long synthetic organism name';
|
||||
for (const [attribution, creator] of [
|
||||
[
|
||||
'(c) Synthetic Photographer, some rights reserved (CC BY-NC)',
|
||||
'Synthetic Photographer',
|
||||
],
|
||||
['© Doe, Jane, some rights reserved (CC BY)', 'Doe, Jane'],
|
||||
['(c) Synthetic Observer, no rights reserved (CC0)', 'Synthetic Observer'],
|
||||
['Doe, Jane, CC BY', 'Doe, Jane'],
|
||||
['Synthetic Photographer, CC BY-NC', 'Synthetic Photographer'],
|
||||
['© Synthetic Photographer (CC BY-SA 4.0)', 'Synthetic Photographer'],
|
||||
['Synthetic Photographer, CC0', 'Synthetic Photographer'],
|
||||
['Synthetic Photographer', 'Synthetic Photographer'],
|
||||
]) {
|
||||
const input = row({ commonName, photo: { ...photo, attribution } });
|
||||
const entry = buildWildlifeThumbnailEntry(input, now);
|
||||
assert.equal(
|
||||
entry.title,
|
||||
commonName,
|
||||
'the overlay host truncates the title',
|
||||
);
|
||||
assert.equal(entry.thumbnailFixedWidth, true);
|
||||
assert.deepEqual(entry.details, [`© ${creator}`]);
|
||||
assert.doesNotMatch(
|
||||
entry.details.join(' '),
|
||||
/Photo:|reserved|CC BY|Not a census/,
|
||||
);
|
||||
}
|
||||
for (const attribution of [
|
||||
'some rights reserved (CC BY)',
|
||||
'(c) ',
|
||||
'CC BY',
|
||||
'CC-BY-NC',
|
||||
'© (CC BY)',
|
||||
]) {
|
||||
assert.equal(
|
||||
buildWildlifeThumbnailEntry(
|
||||
row({ photo: { ...photo, attribution } }),
|
||||
now,
|
||||
),
|
||||
null,
|
||||
'a missing creator never becomes a made-up ambient credit',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('ambient cards fade and scale gradually through the 120 km height band', () => {
|
||||
const entry = buildWildlifeThumbnailEntry(row(), now);
|
||||
const options = {
|
||||
fadeStart: entry.altitudeFadeStart,
|
||||
fadeEnd: entry.altitudeFadeEnd,
|
||||
};
|
||||
assert.equal(altitudeFade(70_000, options), 1);
|
||||
assert.ok(altitudeFade(100_000, options) > altitudeFade(120_000, options));
|
||||
assert.ok(altitudeFade(120_000, options) > 0);
|
||||
assert.equal(altitudeFade(125_000, options), 0);
|
||||
assert.equal(altitudeScale(20_000, entry.altitudeScale), 1);
|
||||
assert.equal(altitudeScale(120_000, entry.altitudeScale), 0.9);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
altitudeScale(119_999, entry.altitudeScale) -
|
||||
altitudeScale(120_001, entry.altitudeScale),
|
||||
) < 0.001,
|
||||
);
|
||||
});
|
||||
|
||||
test('selected rows use the shared selected treatment without duplicating photo content', () => {
|
||||
for (const input of [
|
||||
row(),
|
||||
row({ photo: null }),
|
||||
row({ obscured: true, observedAt: null, accuracyM: null }),
|
||||
acartia(),
|
||||
]) {
|
||||
const label = buildWildlifeSelectedLabel(input);
|
||||
assert.equal(label.id, `wildlife-sightings-label:${input.stableId}`);
|
||||
assert.equal(label.title, input.commonName.toLocaleUpperCase('en'));
|
||||
assert.equal(label.variant, 'label');
|
||||
assert.equal(label.placement, 'above');
|
||||
assert.equal(label.verticalOnly, true);
|
||||
assert.equal(label.protected, true);
|
||||
assert.equal(label.paintLane, 'selected');
|
||||
assert.equal(label.priority, Number.MAX_SAFE_INTEGER);
|
||||
assert.equal(label.interactive, false);
|
||||
assert.equal(label.selected, true);
|
||||
assert.equal(label.details, undefined, 'no link line on canvas cards');
|
||||
assert.equal(label.horizonCull, true);
|
||||
assert.equal(label.edgeFade, 'keyhole');
|
||||
assert.equal(label.accent, wildlifeAccent(input.iconic));
|
||||
for (const field of ['image', 'thumbnailWidth', 'pinned', 'activate'])
|
||||
assert.equal(
|
||||
Object.hasOwn(label, field),
|
||||
false,
|
||||
`${field} is absent from the selected name label`,
|
||||
);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(label)), label);
|
||||
}
|
||||
const ctx = { measureText: (value) => ({ width: String(value).length * 6 }) };
|
||||
const measured = measureOverlayEntry(ctx, buildWildlifeSelectedLabel(row()));
|
||||
assert.equal(
|
||||
measured.titleH,
|
||||
15,
|
||||
'selected names use the shared selected title metrics',
|
||||
);
|
||||
assert.equal(measured.padX, 12);
|
||||
assert.equal(measured.padY, 8);
|
||||
});
|
||||
|
||||
test('selected labels normalize one bounded common-name line and stay absent when unnamed', () => {
|
||||
const label = buildWildlifeSelectedLabel(
|
||||
row({
|
||||
commonName: `Synthetic\n ${'organism '.repeat(10)}`,
|
||||
}),
|
||||
);
|
||||
assert.doesNotMatch(label.title, /\n| /);
|
||||
assert.ok(
|
||||
Array.from(label.title).length <= WILDLIFE_SELECTED_LABEL_MAX_CHARS,
|
||||
);
|
||||
assert.equal(label.title, 'SYNTHETIC ORGANISM ORGANISM ORGANISM…');
|
||||
assert.ok(label.title.endsWith('…'));
|
||||
assert.equal(buildWildlifeSelectedLabel(row({ commonName: null })), null);
|
||||
assert.equal(
|
||||
buildWildlifeSelectedLabel(row({ commonName: null, scientificName: null })),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('detail labels show only common names, with no age, grade, accuracy or photo details', () => {
|
||||
const label = buildWildlifeLabelEntry(row());
|
||||
assert.equal(label.id, 'wildlife-sightings-label:inat:123');
|
||||
assert.equal(label.title, "ANNA'S HUMMINGBIRD");
|
||||
assert.equal(label.variant, 'label');
|
||||
assert.equal(label.collisionGroup, 'ambient-label');
|
||||
assert.equal(label.yieldToCollisionGroup, 'ambient-card');
|
||||
assert.equal(
|
||||
label.stateless,
|
||||
true,
|
||||
'labels never retain overlapping incumbent or exit placements',
|
||||
);
|
||||
assert.equal(label.paintLane, 'ambient-label');
|
||||
assert.equal(label.horizonCull, true);
|
||||
assert.equal(label.edgeFade, 'keyhole');
|
||||
assert.equal(label.interactive, false);
|
||||
assert.equal(label.accent, wildlifeAccent('Aves'));
|
||||
for (const field of [
|
||||
'details',
|
||||
'image',
|
||||
'accuracyM',
|
||||
'quality',
|
||||
'observedAt',
|
||||
'maxDistance',
|
||||
'altitudeFadeStart',
|
||||
'altitudeFadeEnd',
|
||||
'selected',
|
||||
])
|
||||
assert.equal(Object.hasOwn(label, field), false, field);
|
||||
assert.equal(WILDLIFE_LABEL_SOURCE_ID, 'wildlife-sightings-labels');
|
||||
assert.equal(WILDLIFE_LABEL_LIMIT, 150);
|
||||
assert.equal(Object.isFrozen(WILDLIFE_LABEL_SOURCE_OPTIONS), true);
|
||||
assert.equal(WILDLIFE_LABEL_SOURCE_OPTIONS.cohortLimit, WILDLIFE_LABEL_LIMIT);
|
||||
assert.equal(
|
||||
WILDLIFE_LABEL_SOURCE_OPTIONS.collisionCapacity,
|
||||
WILDLIFE_LABEL_LIMIT,
|
||||
);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(label)), label);
|
||||
});
|
||||
|
||||
test('unnamed observations have no ambient label and names come only from the row', () => {
|
||||
for (const commonName of [null, undefined, '', ' \n\t '])
|
||||
assert.equal(buildWildlifeLabelEntry({ ...row(), commonName }), null);
|
||||
assert.equal(
|
||||
buildWildlifeLabelEntry({ ...row(), commonName: 'Red-tailed hawk' }).title,
|
||||
'RED-TAILED HAWK',
|
||||
);
|
||||
});
|
||||
|
||||
test('ambient names select in place and reject activation after their row disappears', () => {
|
||||
let selectedId = null;
|
||||
let exists = true;
|
||||
const label = buildWildlifeLabelEntry(row(), {
|
||||
select(id) {
|
||||
selectedId = id;
|
||||
},
|
||||
hasRow: () => exists,
|
||||
});
|
||||
assert.equal(label.interactive, true);
|
||||
assert.equal(label.accessibilityLabel, "Show Anna's Hummingbird sighting");
|
||||
assert.equal(label.activate(), true);
|
||||
assert.equal(selectedId, 'inat:123');
|
||||
exists = false;
|
||||
assert.equal(label.activate(), false);
|
||||
});
|
||||
|
||||
test('selected name labels retain the existing altitude fade through 400 km', () => {
|
||||
for (const input of [row(), row({ photo: null }), acartia()]) {
|
||||
const entry = buildWildlifeSelectedLabel(input);
|
||||
const options = {
|
||||
fadeStart: entry.altitudeFadeStart,
|
||||
fadeEnd: entry.altitudeFadeEnd,
|
||||
};
|
||||
assert.equal(altitudeFade(30_000, options), 1);
|
||||
assert.ok(altitudeFade(300_000, options) > 0);
|
||||
assert.equal(altitudeFade(400_000, options), 0);
|
||||
assert.equal(altitudeFade(5_000_000, options), 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('compact ages follow shared translated minute, hour and day forms', () => {
|
||||
assert.equal(formatAge(59_999), '1m');
|
||||
assert.equal(formatAge(60 * 60_000), '1h');
|
||||
assert.equal(formatAge(2 * 24 * hour), '2d');
|
||||
assert.equal(formatAge(-1), null);
|
||||
assert.equal(formatAge(NaN), null);
|
||||
assert.equal(wildlifeAccent('Protozoa'), '#9aa7b3');
|
||||
});
|
||||
|
||||
function ambientFixture(rows, screens) {
|
||||
const state = {
|
||||
enabled: true,
|
||||
renderedRows: rows,
|
||||
ambientRows: [],
|
||||
ambientIds: [],
|
||||
labelRows: [],
|
||||
selectedId: null,
|
||||
positions: new Map(
|
||||
rows.map((row) => [row.stableId, screens.get(row.stableId)]),
|
||||
),
|
||||
pointById: new Map(),
|
||||
rowById: new Map(rows.map((row) => [row.stableId, row])),
|
||||
viewer: {
|
||||
scene: { canvas: { clientWidth: 1200, clientHeight: 900 } },
|
||||
camera: { positionCartographic: { height: 30_000 } },
|
||||
},
|
||||
};
|
||||
const photos = [];
|
||||
const painted = [];
|
||||
const entries = new Map();
|
||||
let cardSourceOptions;
|
||||
const selection = createSelection({
|
||||
state,
|
||||
services: {
|
||||
now: () => now,
|
||||
project: (_scene, position) => position,
|
||||
overlayHost: {
|
||||
setEntries(id, _entries, options) {
|
||||
entries.set(id, _entries);
|
||||
if (id === 'wildlife-sightings') cardSourceOptions = options;
|
||||
},
|
||||
},
|
||||
},
|
||||
parts: {
|
||||
rendering: {
|
||||
occluder: () => null,
|
||||
requestRender() {},
|
||||
setPhotoCardVisibility: (ids) => painted.push([...ids]),
|
||||
},
|
||||
photos: { get: () => null, setAmbient: (rows) => photos.push(rows) },
|
||||
},
|
||||
});
|
||||
return {
|
||||
state,
|
||||
selection,
|
||||
photos,
|
||||
painted,
|
||||
entries,
|
||||
get cardSourceOptions() {
|
||||
return cardSourceOptions;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('label candidates and host priorities use ambientScore before the candidate cap', () => {
|
||||
const rows = Array.from({ length: WILDLIFE_LABEL_LIMIT + 20 }, (_, i) =>
|
||||
row({
|
||||
stableId: `inat:${String(i).padStart(3, '0')}`,
|
||||
commonName: `Synthetic bird ${i}`,
|
||||
favesCount: i,
|
||||
}),
|
||||
);
|
||||
const f = ambientFixture(
|
||||
rows,
|
||||
new Map(
|
||||
rows.map((r, i) => [
|
||||
r.stableId,
|
||||
{ x: 10 + (i % 40) * 28, y: 200 + Math.floor(i / 40) * 28 },
|
||||
]),
|
||||
),
|
||||
);
|
||||
f.selection.refreshAmbient();
|
||||
const entries = f.entries.get('wildlife-sightings-labels');
|
||||
assert.equal(entries.length, WILDLIFE_LABEL_LIMIT);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.id),
|
||||
rows
|
||||
.slice(20)
|
||||
.reverse()
|
||||
.map((r) => `wildlife-sightings-label:${r.stableId}`),
|
||||
);
|
||||
for (const r of f.state.labelRows)
|
||||
assert.equal(
|
||||
buildWildlifeLabelEntry(r, { nowMs: now }).priority,
|
||||
ambientScore(r, now),
|
||||
);
|
||||
});
|
||||
|
||||
test('ambient source forwards actual painted membership to badge visibility', () => {
|
||||
const r = row();
|
||||
const f = ambientFixture([r], new Map([[r.stableId, { x: 150, y: 150 }]]));
|
||||
f.selection.refreshAmbient();
|
||||
assert.deepEqual(f.painted, [], 'a queued photo is not a painted card');
|
||||
f.cardSourceOptions.onVisibilityChange(new Set([`sighting:${r.stableId}`]));
|
||||
assert.deepEqual(f.painted, [[`sighting:${r.stableId}`]]);
|
||||
f.cardSourceOptions.onVisibilityChange(new Set());
|
||||
assert.deepEqual(f.painted.at(-1), []);
|
||||
});
|
||||
|
||||
test('new ambient cards admit at most one highest-score photo per screen region', () => {
|
||||
const rows = Array.from({ length: 24 }, (_, i) =>
|
||||
row({ stableId: `inat:${i}`, favesCount: i }),
|
||||
);
|
||||
const screens = new Map(
|
||||
rows.map((row, i) => [
|
||||
row.stableId,
|
||||
{
|
||||
x: 150 + (Math.floor(i / 2) % 4) * 300,
|
||||
y: 150 + Math.floor(i / 8) * 300,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const f = ambientFixture(rows, screens);
|
||||
f.selection.refreshAmbient();
|
||||
assert.equal(f.state.ambientRows.length, 12);
|
||||
assert.ok(
|
||||
f.state.ambientRows.every(
|
||||
(row) => Number(row.stableId.split(':')[1]) % 2 === 1,
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
new Set(
|
||||
f.state.ambientRows.map((row) => {
|
||||
const p = screens.get(row.stableId);
|
||||
return `${Math.floor(p.x / 300)}:${Math.floor(p.y / 300)}`;
|
||||
}),
|
||||
).size,
|
||||
12,
|
||||
);
|
||||
});
|
||||
|
||||
test('pans retain every on-screen photo and refreshes only fill free slots', () => {
|
||||
const a = row({ stableId: 'inat:a' });
|
||||
const b = row({ stableId: 'inat:b' });
|
||||
const c = row({ stableId: 'inat:c', favesCount: 1000 });
|
||||
const screens = new Map([
|
||||
[a.stableId, { x: 150, y: 150 }],
|
||||
[b.stableId, { x: 450, y: 150 }],
|
||||
[c.stableId, { x: 170, y: 150 }],
|
||||
]);
|
||||
const f = ambientFixture([a, b], screens);
|
||||
f.selection.refreshAmbient();
|
||||
const initial = f.state.ambientIds.slice();
|
||||
// This pan moves the two incumbents into one region. Neither may be evicted.
|
||||
f.state.positions.set(b.stableId, { x: 200, y: 160 });
|
||||
f.selection.refreshAmbient();
|
||||
assert.deepEqual(f.state.ambientIds, initial);
|
||||
f.state.rowById.set(c.stableId, c);
|
||||
f.state.positions.set(c.stableId, screens.get(c.stableId));
|
||||
// A new random sample can omit both incumbent rows from the rendered batch.
|
||||
f.state.renderedRows = [c];
|
||||
f.selection.refreshAmbient();
|
||||
assert.deepEqual(f.state.ambientIds, initial);
|
||||
assert.deepEqual(
|
||||
new Set(f.selection.retainedAmbientRows().map((row) => row.stableId)),
|
||||
new Set(initial),
|
||||
);
|
||||
f.state.positions.set(a.stableId, { x: -1, y: 150 });
|
||||
assert.deepEqual(
|
||||
f.selection.retainedAmbientRows().map((row) => row.stableId),
|
||||
[b.stableId],
|
||||
);
|
||||
});
|
||||
|
||||
test('ambient show/hide thresholds have hysteresis without small-zoom toggles', () => {
|
||||
const a = row();
|
||||
const f = ambientFixture([a], new Map([[a.stableId, { x: 150, y: 150 }]]));
|
||||
const height = f.state.viewer.camera.positionCartographic;
|
||||
height.height = WILDLIFE_CARD_SHOW_MAX_METERS - 1;
|
||||
f.selection.refreshAmbient();
|
||||
assert.equal(f.state.ambientRows.length, 1);
|
||||
for (const altitude of [110_001, 119_999, 120_001, 124_999, 110_001]) {
|
||||
height.height = altitude;
|
||||
f.selection.refreshAmbient();
|
||||
assert.equal(f.state.ambientRows.length, 1);
|
||||
}
|
||||
height.height = WILDLIFE_CARD_HIDE_MAX_METERS;
|
||||
f.selection.refreshAmbient();
|
||||
assert.equal(f.state.ambientRows.length, 0);
|
||||
height.height = WILDLIFE_CARD_HIDE_MAX_METERS - 1;
|
||||
f.selection.refreshAmbient();
|
||||
assert.equal(f.state.ambientRows.length, 0);
|
||||
height.height = WILDLIFE_CARD_SHOW_MAX_METERS;
|
||||
f.selection.refreshAmbient();
|
||||
assert.equal(f.state.ambientRows.length, 1);
|
||||
});
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
import { t, formatNumber, translateKnown } from '../../i18n/index.js';
|
||||
import { msg } from '../../i18n/marker.js';
|
||||
import { translateFeedError } from '../../ui/statusText.js';
|
||||
import { wildlifeIconicGroup } from './model.js';
|
||||
import { formatAge } from './photoText.js';
|
||||
import {
|
||||
ICONIC_TINTS,
|
||||
ICONIC_LEGEND_ORDER,
|
||||
TAXA_PRESETS,
|
||||
WILDLIFE_CONFIRMED_CHIP_ID,
|
||||
WILDLIFE_GRID_HEIGHT_METERS,
|
||||
WILDLIFE_UPDATE_INTERVAL_MS,
|
||||
} from './policy.js';
|
||||
|
||||
const LEGEND_LABELS = Object.freeze({
|
||||
Aves: msg('Birds'),
|
||||
Mammalia: msg('Mammals'),
|
||||
Reptilia: msg('Reptiles'),
|
||||
Amphibia: msg('Amphibians'),
|
||||
Actinopterygii: msg('Ray-finned fishes'),
|
||||
Insecta: msg('Insects'),
|
||||
Arachnida: msg('Arachnids'),
|
||||
Plantae: msg('Plants'),
|
||||
Fungi: msg('Fungi'),
|
||||
Animalia: msg('Other animals'),
|
||||
Unknown: msg('Other or unknown'),
|
||||
});
|
||||
|
||||
/** Own layer-row descriptors and serializable filter options. */
|
||||
export function createControls({ state, services, parts }) {
|
||||
function notify() {
|
||||
try {
|
||||
state.controlsListener?.();
|
||||
} catch {
|
||||
/* repaint listeners are optional */
|
||||
}
|
||||
}
|
||||
|
||||
function chip({ id, label, title, params, key, value }) {
|
||||
const pending =
|
||||
state.pendingParams?.keys.includes(key) &&
|
||||
(key === 'confirmed' || state.pendingParams[key] === value);
|
||||
const failedKey = state.failedParams?.keys.includes(key);
|
||||
const failed =
|
||||
failedKey && (key === 'confirmed' || state.failedParams[key] === value);
|
||||
const active =
|
||||
!pending &&
|
||||
(failedKey
|
||||
? state[key] === value
|
||||
: !!state.drawnParams && state.drawnParams[key] === value);
|
||||
return {
|
||||
id,
|
||||
label: `${label}${pending ? ' ···' : failed ? ' ✕' : ''}`,
|
||||
title: failed
|
||||
? t('{label} {detail} — click to retry', {
|
||||
label,
|
||||
detail: translateFeedError(
|
||||
state.error || msg('Wildlife sightings source unavailable'),
|
||||
),
|
||||
})
|
||||
: title,
|
||||
active,
|
||||
busy: !!pending,
|
||||
disabled: !!pending,
|
||||
state: pending
|
||||
? 'loading'
|
||||
: failed
|
||||
? 'error'
|
||||
: active
|
||||
? 'active'
|
||||
: 'idle',
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
const methods = {
|
||||
id: 'wildlife-sightings',
|
||||
name: 'Wildlife Sightings',
|
||||
icon: '🦉',
|
||||
source: 'iNaturalist',
|
||||
updateInterval: WILDLIFE_UPDATE_INTERVAL_MS,
|
||||
|
||||
/** Route camera refreshes through the application's lifecycle manager. */
|
||||
attachDataManager(manager) {
|
||||
state.manager = manager;
|
||||
},
|
||||
/** Connect the layer row's repaint listener. */
|
||||
setRowControlsListener(listener) {
|
||||
state.controlsListener = typeof listener === 'function' ? listener : null;
|
||||
},
|
||||
/** Snapshot the requested filters for share links and restoration. */
|
||||
getParams() {
|
||||
return { confirmed: state.confirmed, taxa: state.taxa };
|
||||
},
|
||||
/** Apply filters while retaining the last accepted display until success. */
|
||||
setParams(params = {}) {
|
||||
const next = {
|
||||
confirmed:
|
||||
typeof params.confirmed === 'boolean'
|
||||
? params.confirmed
|
||||
: state.confirmed,
|
||||
taxa: TAXA_PRESETS.some((preset) => preset.value === params.taxa)
|
||||
? params.taxa
|
||||
: state.taxa,
|
||||
};
|
||||
const changed = ['confirmed', 'taxa'].filter(
|
||||
(key) => next[key] !== state[key],
|
||||
);
|
||||
if (!changed.length && !state.failedParams) return true;
|
||||
// Compare with the displayed filters so disjoint edits stay pending.
|
||||
// Initially taxa awaits data while the default-off toggle remains idle.
|
||||
const drawn = state.drawnParams || { confirmed: false, taxa: null };
|
||||
const keys = ['confirmed', 'taxa'].filter(
|
||||
(key) => next[key] !== drawn[key],
|
||||
);
|
||||
if (!changed.length && state.failedParams) {
|
||||
for (const key of state.failedParams.keys) {
|
||||
if (!keys.includes(key)) keys.push(key);
|
||||
}
|
||||
}
|
||||
state.confirmed = next.confirmed;
|
||||
state.taxa = next.taxa;
|
||||
state.pendingParams = keys.length ? { ...next, keys } : null;
|
||||
state.failedParams = null;
|
||||
// Replace the LRU with the accepted result so taxa never get mixed.
|
||||
state.pendingClear = true;
|
||||
state.lastQueryKey = null;
|
||||
state.request?.abort();
|
||||
state.background?.abort();
|
||||
parts.lifecycle.scheduleRefresh();
|
||||
notify();
|
||||
return true;
|
||||
},
|
||||
/** Describe filter chips, nonempty legend groups and provider attribution. */
|
||||
getRowControls() {
|
||||
const confirmedFailed = state.failedParams?.keys.includes('confirmed');
|
||||
const chips = [
|
||||
chip({
|
||||
id: WILDLIFE_CONFIRMED_CHIP_ID,
|
||||
label: t('CONFIRMED'),
|
||||
title: t('Show only community-confirmed (research-grade) sightings'),
|
||||
params: {
|
||||
confirmed: confirmedFailed ? state.confirmed : !state.confirmed,
|
||||
},
|
||||
key: 'confirmed',
|
||||
value: true,
|
||||
}),
|
||||
...TAXA_PRESETS.map((preset) =>
|
||||
chip({
|
||||
id: preset.id,
|
||||
label: translateKnown(preset.label),
|
||||
title: translateKnown(preset.title),
|
||||
params: { taxa: preset.value },
|
||||
key: 'taxa',
|
||||
value: preset.value,
|
||||
}),
|
||||
),
|
||||
];
|
||||
const counts = new Map();
|
||||
for (const row of state.renderedRows) {
|
||||
const group = wildlifeIconicGroup(row.iconic);
|
||||
counts.set(group, (counts.get(group) || 0) + 1);
|
||||
}
|
||||
const legend = ICONIC_LEGEND_ORDER.filter((key) => counts.has(key)).map(
|
||||
(key, index) => ({
|
||||
label: translateKnown(LEGEND_LABELS[key]),
|
||||
color: ICONIC_TINTS[key],
|
||||
count: counts.get(key),
|
||||
...(index === 0
|
||||
? {
|
||||
blurb: t(
|
||||
'Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.',
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
if (!state.lastUpdate && !legend.length) return { chips, legend: [] };
|
||||
const acartiaCount = state.renderedRows.filter(
|
||||
(row) => row.origin === 'acartia',
|
||||
).length;
|
||||
const info = acartiaCount
|
||||
? t(
|
||||
'iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record',
|
||||
{ count: formatNumber(acartiaCount) },
|
||||
)
|
||||
: t('iNaturalist contributors · licences per record');
|
||||
return { chips, legend, info };
|
||||
},
|
||||
/** Report the visible count, feed state and retry guidance. */
|
||||
getStats() {
|
||||
const grid = state.gridAvailable === true;
|
||||
const count =
|
||||
state.sightingCount ??
|
||||
(grid ? (state.gridTotal ?? 0) : state.renderedRows.length);
|
||||
const loading = state.loading && !state.lastUpdate;
|
||||
const refreshing = state.loading && !!state.lastUpdate;
|
||||
const age = state.lastUpdate
|
||||
? formatAge(services.now() - state.lastUpdate)
|
||||
: '';
|
||||
const minutes = state.lastUpdate
|
||||
? Math.max(0, (services.now() - state.lastUpdate) / 60_000)
|
||||
: 0;
|
||||
const liveAge =
|
||||
minutes < 1
|
||||
? t('<1m ago')
|
||||
: minutes < 90
|
||||
? t('{count}m ago', { count: Math.floor(minutes) })
|
||||
: t('{count}h ago', { count: Math.round(minutes / 60) });
|
||||
const stats = {
|
||||
count,
|
||||
lastUpdate: state.lastUpdate,
|
||||
error: state.error,
|
||||
stale: state.stale,
|
||||
loading,
|
||||
refreshing,
|
||||
countLabel: t('{count} sightings in view', {
|
||||
count: formatNumber(count),
|
||||
}),
|
||||
loadingLabel: loading
|
||||
? t('loading...')
|
||||
: refreshing
|
||||
? t('refreshing...')
|
||||
: state.stale
|
||||
? t('STALE · cached {age}', { age })
|
||||
: state.lastUpdate
|
||||
? t('LIVE · updated {age}', { age: liveAge })
|
||||
: '',
|
||||
};
|
||||
if (
|
||||
!state.error &&
|
||||
!state.query &&
|
||||
state.viewer?.camera?.positionCartographic?.height >
|
||||
WILDLIFE_GRID_HEIGHT_METERS
|
||||
) {
|
||||
// i18n-ignore: feed-state protocol value
|
||||
stats.status = 'zoom-in';
|
||||
stats.statusMessage = msg('Zoom in for local sightings');
|
||||
} else if (!state.error && state.lastUpdate && !count) {
|
||||
// i18n-ignore: feed-state protocol value
|
||||
stats.status = 'empty';
|
||||
stats.statusMessage = msg('No recent sightings in view');
|
||||
}
|
||||
if (state.retryAt > services.now())
|
||||
stats.retryInSec = Math.ceil((state.retryAt - services.now()) / 1000);
|
||||
return stats;
|
||||
},
|
||||
};
|
||||
return { methods, notify };
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import { wildlifeGridCellBounds } from './gridViewport.js';
|
||||
import {
|
||||
createWildlifeWorkQueue,
|
||||
drainWildlifeWork,
|
||||
WILDLIFE_WORK_UNIT,
|
||||
} from './performance.js';
|
||||
|
||||
export const WILDLIFE_DENSITY_OVERLAP = 4;
|
||||
const KERNEL_SIGMA = 0.7;
|
||||
export const WILDLIFE_DENSITY_MAX_ALPHA = 0.34;
|
||||
const DENSITY_MIN_ALPHA = 0.012;
|
||||
const DENSITY_RAMP_GAMMA = 3;
|
||||
const METERS_PER_DEGREE = (2 * Math.PI * 6378137) / 360;
|
||||
const FADE_STEPS = 3;
|
||||
const FADE_STEP_MS = 60;
|
||||
const SPRITE_SIZE = 128;
|
||||
// Fade coarse splats before they read as haze across the whole Earth disc.
|
||||
const ALTITUDE_FADE_START = 3_000_000;
|
||||
const ALTITUDE_FADE_END = 8_000_000;
|
||||
// Sparse cells read cool teal; hotspots warm toward amber. Sensor styles keep
|
||||
// one neutral or phosphor tint.
|
||||
const NORMAL_TINT = { low: [0.3, 0.8, 0.78], high: [1, 0.78, 0.38] };
|
||||
const MONO_TINT = { low: [0.96, 0.96, 0.96], high: [0.96, 0.96, 0.96] };
|
||||
const CRT_TINT = { low: [0.48, 1, 0.72], high: [0.48, 1, 0.72] };
|
||||
|
||||
/** View-relative alpha keeps hotspots distinct as cell counts grow on zoom-out. */
|
||||
export function wildlifeDensityIntensity(rank) {
|
||||
if (!(rank > 0) || !Number.isFinite(rank)) return 0;
|
||||
return (
|
||||
DENSITY_MIN_ALPHA +
|
||||
(WILDLIFE_DENSITY_MAX_ALPHA - DENSITY_MIN_ALPHA) *
|
||||
Math.min(1, rank) ** DENSITY_RAMP_GAMMA
|
||||
);
|
||||
}
|
||||
|
||||
/** Rank function over the snapshot's positive counts: the share of cells with
|
||||
* a count at or below the given one. */
|
||||
export function wildlifeDensityRanks(cells = []) {
|
||||
const counts = [];
|
||||
for (const cell of cells)
|
||||
if (Number.isSafeInteger(cell?.cellCount) && cell.cellCount > 0)
|
||||
counts.push(cell.cellCount);
|
||||
counts.sort((a, b) => a - b);
|
||||
return (count) => {
|
||||
if (!counts.length || !(count > 0)) return 0;
|
||||
let low = 0;
|
||||
let high = counts.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
if (counts[mid] <= count) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return low / counts.length;
|
||||
};
|
||||
}
|
||||
|
||||
/** Truncated Gaussian in source-cell widths; overlapping splats hide the grid. */
|
||||
export function wildlifeDensityKernel(distance) {
|
||||
const radius = WILDLIFE_DENSITY_OVERLAP / 2;
|
||||
if (!Number.isFinite(distance) || distance < 0 || distance >= radius)
|
||||
return 0;
|
||||
const edge = Math.exp(-(radius ** 2) / (2 * KERNEL_SIGMA ** 2));
|
||||
return (
|
||||
(Math.exp(-(distance ** 2) / (2 * KERNEL_SIGMA ** 2)) - edge) / (1 - edge)
|
||||
);
|
||||
}
|
||||
|
||||
const DENSITY_IMAGE = `data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><defs><radialGradient id="g">${Array.from(
|
||||
{ length: 33 },
|
||||
(_, i) =>
|
||||
`<stop offset="${i / 32}" stop-color="white" stop-opacity="${wildlifeDensityKernel(((i / 32) * WILDLIFE_DENSITY_OVERLAP) / 2).toFixed(5)}"/>`,
|
||||
).join(
|
||||
'',
|
||||
)}</radialGradient></defs><circle cx="64" cy="64" r="64" fill="url(#g)"/></svg>`,
|
||||
)}`;
|
||||
|
||||
function tintForStyle(style) {
|
||||
if (['mono', 'surveillance', 'thermal', 'noir', 'nvg'].includes(style))
|
||||
return MONO_TINT;
|
||||
return ['crt', 'retro'].includes(style) ? CRT_TINT : NORMAL_TINT;
|
||||
}
|
||||
|
||||
function intersects(bounds, boxes, padding = 0) {
|
||||
if (!boxes?.length) return true;
|
||||
const latitude = (bounds.north - bounds.south) * padding;
|
||||
const longitude = (bounds.east - bounds.west) * padding;
|
||||
for (const box of boxes) {
|
||||
if (
|
||||
bounds.north + latitude < box.south ||
|
||||
bounds.south - latitude > box.north
|
||||
)
|
||||
continue;
|
||||
// Both a padded splat and the viewport can straddle the antimeridian.
|
||||
const east = box.east < box.west ? box.east + 360 : box.east;
|
||||
const left = bounds.west - longitude;
|
||||
const right = bounds.east + longitude;
|
||||
if (
|
||||
(right >= box.west && left <= east) ||
|
||||
(right + 360 >= box.west && left + 360 <= east) ||
|
||||
(right - 360 >= box.west && left - 360 <= east)
|
||||
)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fit the entire kernel inside the globe's tangent cone, plus 5% of its radius.
|
||||
// A visible centre alone lets a depth-free billboard bleed across the limb.
|
||||
// The inscribed sphere is conservative for WGS84 and needs no projections.
|
||||
function horizonTest(cameraPosition, radius) {
|
||||
const { x, y, z } = cameraPosition || {};
|
||||
const distance = Math.hypot(x, y, z);
|
||||
if (!Number.isFinite(distance) || distance <= radius) return null;
|
||||
const nx = x / distance;
|
||||
const ny = y / distance;
|
||||
const nz = z / distance;
|
||||
const sinAngle = radius / distance;
|
||||
const cosAngleSquared = 1 - sinAngle ** 2;
|
||||
const horizonPlane = radius * sinAngle;
|
||||
return (position, kernelRadius) => {
|
||||
const along = position.x * nx + position.y * ny + position.z * nz;
|
||||
if (along <= horizonPlane) return false;
|
||||
const clearance = sinAngle * (distance - along) - kernelRadius * 1.05;
|
||||
const acrossSquared = Math.max(
|
||||
0,
|
||||
position.x ** 2 + position.y ** 2 + position.z ** 2 - along ** 2,
|
||||
);
|
||||
return clearance > 0 && acrossSquared * cosAngleSquared < clearance ** 2;
|
||||
};
|
||||
}
|
||||
|
||||
/** Own noninteractive, world-sized glow splats, arrival fades and style changes. */
|
||||
/** A world-sized kernel fills the screen once the camera is inside it, e.g.
|
||||
* while flying in before the closer view's tiles arrive. Cesium fades it on the
|
||||
* GPU: gone within half its width, full beyond twice its width. */
|
||||
export const WILDLIFE_DENSITY_NEAR_FADE = Object.freeze({ near: 0.5, far: 2 });
|
||||
|
||||
export function createWildlifeDensity({
|
||||
cesium: C,
|
||||
viewer,
|
||||
positionForCell = (cell) => C.Cartesian3.fromDegrees(cell.lon, cell.lat),
|
||||
requestRender = () => {},
|
||||
setTimer = (fn, delay) => setTimeout(fn, delay),
|
||||
clearTimer = (timer) => clearTimeout(timer),
|
||||
requestIdle,
|
||||
cancelIdle,
|
||||
eventTarget = globalThis.window,
|
||||
documentRef = globalThis.document,
|
||||
} = {}) {
|
||||
let collection = null;
|
||||
const nearFade = (width) =>
|
||||
new C.NearFarScalar(
|
||||
width * WILDLIFE_DENSITY_NEAR_FADE.near,
|
||||
0,
|
||||
width * WILDLIFE_DENSITY_NEAR_FADE.far,
|
||||
1,
|
||||
);
|
||||
let metadata = new WeakMap();
|
||||
const entries = new Map();
|
||||
const fading = new Set();
|
||||
let fadeTimer = null;
|
||||
let destroyed = false;
|
||||
let generation = 0;
|
||||
let cockpitVision = false;
|
||||
let tint = tintForStyle(documentRef?.documentElement?.dataset?.gevStyle);
|
||||
let listening = false;
|
||||
const fadeQueue = createWildlifeWorkQueue({ requestIdle, cancelIdle });
|
||||
const styleQueue = createWildlifeWorkQueue({ requestIdle, cancelIdle });
|
||||
|
||||
// BillboardCollection does not implement Primitive's allowPicking option.
|
||||
// Skipping its public pick update prevents its translucent quads from
|
||||
// intercepting observations, including pixels with a faint glow alpha.
|
||||
class DensityCollection extends C.BillboardCollection {
|
||||
constructor() {
|
||||
// Cesium's TRANSLUCENT-only option writes depth and occludes badges.
|
||||
super();
|
||||
this.allowPicking = false;
|
||||
}
|
||||
update(frameState) {
|
||||
if (frameState.passes?.pick) return;
|
||||
return super.update(frameState);
|
||||
}
|
||||
}
|
||||
|
||||
function color(entry) {
|
||||
const mix = (i) => tint.low[i] + (tint.high[i] - tint.low[i]) * entry.rank;
|
||||
return new C.Color(
|
||||
mix(0),
|
||||
mix(1),
|
||||
mix(2),
|
||||
(wildlifeDensityIntensity(entry.rank) * entry.opacity * entry.fade) /
|
||||
FADE_STEPS,
|
||||
);
|
||||
}
|
||||
|
||||
function* styleWork() {
|
||||
let visits = 0;
|
||||
for (const entry of entries.values()) {
|
||||
if (visits++ % WILDLIFE_WORK_UNIT === 0) yield 'density-style';
|
||||
entry.point.color = color(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function setStyle(style) {
|
||||
const next = tintForStyle(style);
|
||||
if (next === tint) return;
|
||||
tint = next;
|
||||
if (entries.size)
|
||||
styleQueue.start(styleWork(), () => requestRender('density-style'));
|
||||
}
|
||||
|
||||
function onStyle(event) {
|
||||
if (!cockpitVision) setStyle(event?.detail?.style);
|
||||
}
|
||||
|
||||
function onVision(event) {
|
||||
cockpitVision = event?.detail?.cockpit === true;
|
||||
setStyle(event?.detail?.style);
|
||||
}
|
||||
|
||||
function ensureCollection() {
|
||||
if (collection) return;
|
||||
collection = new DensityCollection();
|
||||
viewer.scene.primitives.add(collection);
|
||||
if (!listening) {
|
||||
tint = tintForStyle(documentRef?.documentElement?.dataset?.gevStyle);
|
||||
eventTarget?.addEventListener?.('gev:style-change', onStyle);
|
||||
eventTarget?.addEventListener?.('gev:vision-change', onVision);
|
||||
listening = true;
|
||||
}
|
||||
}
|
||||
|
||||
function* fadeWork() {
|
||||
let visits = 0;
|
||||
for (const entry of fading) {
|
||||
if (visits++ % WILDLIFE_WORK_UNIT === 0) yield 'density-fade';
|
||||
entry.fade++;
|
||||
entry.point.color = color(entry);
|
||||
if (entry.fade >= FADE_STEPS) fading.delete(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFade() {
|
||||
if (fadeTimer !== null || fadeQueue.pending || !fading.size || destroyed)
|
||||
return;
|
||||
fadeTimer = setTimer(() => {
|
||||
fadeTimer = null;
|
||||
fadeQueue.start(fadeWork(), () => {
|
||||
requestRender('density-fade');
|
||||
scheduleFade();
|
||||
});
|
||||
}, FADE_STEP_MS);
|
||||
}
|
||||
|
||||
function dataForCell(cell, operations) {
|
||||
if (
|
||||
!cell ||
|
||||
!Number.isFinite(cell.lat) ||
|
||||
Math.abs(cell.lat) > 90 ||
|
||||
!Number.isFinite(cell.lon) ||
|
||||
Math.abs(cell.lon) > 180 ||
|
||||
!Number.isSafeInteger(cell.cellCount) ||
|
||||
cell.cellCount <= 0
|
||||
)
|
||||
return null;
|
||||
const old = metadata.get(cell);
|
||||
if (
|
||||
old?.id === cell.id &&
|
||||
old.lat === cell.lat &&
|
||||
old.lon === cell.lon &&
|
||||
old.count === cell.cellCount
|
||||
)
|
||||
return old;
|
||||
const bounds =
|
||||
old?.id === cell.id ? old.bounds : wildlifeGridCellBounds(cell.id);
|
||||
if (!bounds) return null;
|
||||
const samePosition = old?.lat === cell.lat && old?.lon === cell.lon;
|
||||
const data = {
|
||||
id: cell.id,
|
||||
lat: cell.lat,
|
||||
lon: cell.lon,
|
||||
count: cell.cellCount,
|
||||
bounds,
|
||||
width:
|
||||
WILDLIFE_DENSITY_OVERLAP *
|
||||
METERS_PER_DEGREE *
|
||||
Math.max(
|
||||
(bounds.east - bounds.west) * Math.cos((cell.lat * Math.PI) / 180),
|
||||
bounds.north - bounds.south,
|
||||
),
|
||||
position: samePosition ? old.position : positionForCell(cell),
|
||||
};
|
||||
operations.densityMetadataBuilds++;
|
||||
if (!samePosition) operations.densityPositionBuilds++;
|
||||
metadata.set(cell, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Reconcile the authoritative snapshot, including successful empty tiles. */
|
||||
function* updateWork(
|
||||
cells = [],
|
||||
{ boxes, isPointVisible, onOperations = () => {} } = {},
|
||||
) {
|
||||
const token = ++generation;
|
||||
const operations = {
|
||||
densityVisits: 0,
|
||||
densityRetirementVisits: 0,
|
||||
densityMetadataBuilds: 0,
|
||||
densityPositionBuilds: 0,
|
||||
densityWrites: 0,
|
||||
densityCreated: 0,
|
||||
densityRemoved: 0,
|
||||
};
|
||||
let sightingCount = 0;
|
||||
let changed = false;
|
||||
const height = viewer.camera?.positionCartographic?.height ?? 0;
|
||||
const opacity = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(ALTITUDE_FADE_END - height) /
|
||||
(ALTITUDE_FADE_END - ALTITUDE_FADE_START),
|
||||
),
|
||||
);
|
||||
const insideHorizon = horizonTest(
|
||||
viewer.camera?.positionWC,
|
||||
C.Ellipsoid?.WGS84?.minimumRadius ?? 6_356_752.314245,
|
||||
);
|
||||
const rankOf = wildlifeDensityRanks(cells);
|
||||
for (const cell of cells) {
|
||||
if (operations.densityVisits++ % WILDLIFE_WORK_UNIT === 0) {
|
||||
yield 'density';
|
||||
if (destroyed || token !== generation)
|
||||
return { sightingCount: 0, changed: false, size: entries.size };
|
||||
}
|
||||
const data = dataForCell(cell, operations);
|
||||
if (
|
||||
!data ||
|
||||
!intersects(data.bounds, boxes, (WILDLIFE_DENSITY_OVERLAP - 1) / 2) ||
|
||||
isPointVisible?.(data.position) === false
|
||||
)
|
||||
continue;
|
||||
if (intersects(data.bounds, boxes))
|
||||
sightingCount = Math.min(
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
sightingCount + data.count,
|
||||
);
|
||||
// These are visual decisions only; counts retain every visible cell.
|
||||
if (
|
||||
opacity === 0 ||
|
||||
insideHorizon?.(data.position, data.width / 2) === false
|
||||
)
|
||||
continue;
|
||||
const rank = rankOf(data.count);
|
||||
let entry = entries.get(data.id);
|
||||
if (!entry) {
|
||||
ensureCollection();
|
||||
entry = { data, rank, opacity, fade: 1, seen: token, point: null };
|
||||
entry.point = collection.add({
|
||||
position: data.position,
|
||||
image: DENSITY_IMAGE,
|
||||
// Cesium packs width in 16 bits even for meter-sized billboards.
|
||||
// Put the world diameter in its unrestricted scale attribute so
|
||||
// continental kernels never clamp into thin vertical ellipses.
|
||||
width: SPRITE_SIZE,
|
||||
height: SPRITE_SIZE,
|
||||
scale: data.width / SPRITE_SIZE,
|
||||
sizeInMeters: true,
|
||||
color: color(entry),
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
translucencyByDistance: nearFade(data.width),
|
||||
});
|
||||
entries.set(data.id, entry);
|
||||
fading.add(entry);
|
||||
operations.densityCreated++;
|
||||
changed = true;
|
||||
} else {
|
||||
entry.seen = token;
|
||||
if (entry.data.position !== data.position) {
|
||||
entry.point.position = data.position;
|
||||
operations.densityWrites++;
|
||||
changed = true;
|
||||
}
|
||||
if (entry.data.width !== data.width) {
|
||||
entry.point.scale = data.width / SPRITE_SIZE;
|
||||
entry.point.translucencyByDistance = nearFade(data.width);
|
||||
operations.densityWrites++;
|
||||
changed = true;
|
||||
}
|
||||
if (entry.rank !== rank || entry.opacity !== opacity) {
|
||||
entry.rank = rank;
|
||||
entry.opacity = opacity;
|
||||
entry.point.color = color(entry);
|
||||
operations.densityWrites++;
|
||||
changed = true;
|
||||
}
|
||||
entry.data = data;
|
||||
}
|
||||
}
|
||||
for (const [id, entry] of entries) {
|
||||
if (operations.densityRetirementVisits++ % WILDLIFE_WORK_UNIT === 0) {
|
||||
yield 'density';
|
||||
if (destroyed || token !== generation)
|
||||
return { sightingCount: 0, changed: false, size: entries.size };
|
||||
}
|
||||
if (entry.seen === token) continue;
|
||||
collection.remove(entry.point);
|
||||
fading.delete(entry);
|
||||
entries.delete(id);
|
||||
operations.densityRemoved++;
|
||||
changed = true;
|
||||
}
|
||||
if (!entries.size && collection) clear(true);
|
||||
else scheduleFade();
|
||||
if (changed) requestRender('density');
|
||||
onOperations(operations);
|
||||
return { sightingCount, changed, size: entries.size };
|
||||
}
|
||||
|
||||
/** Release the texture atlas, billboards, cached coordinates and all work. */
|
||||
function clear(keepMetadata = false) {
|
||||
generation++;
|
||||
fadeQueue.cancel();
|
||||
styleQueue.cancel();
|
||||
if (fadeTimer !== null) clearTimer(fadeTimer);
|
||||
fadeTimer = null;
|
||||
entries.clear();
|
||||
fading.clear();
|
||||
if (!keepMetadata) metadata = new WeakMap();
|
||||
if (collection) {
|
||||
if (
|
||||
viewer.scene.primitives.remove(collection) !== true &&
|
||||
!collection.isDestroyed?.()
|
||||
)
|
||||
collection.destroy();
|
||||
collection = null;
|
||||
}
|
||||
if (listening) {
|
||||
eventTarget?.removeEventListener?.('gev:style-change', onStyle);
|
||||
eventTarget?.removeEventListener?.('gev:vision-change', onVision);
|
||||
listening = false;
|
||||
}
|
||||
cockpitVision = false;
|
||||
}
|
||||
|
||||
return {
|
||||
updateWork,
|
||||
update: (cells, options) => drainWildlifeWork(updateWork(cells, options)),
|
||||
clear,
|
||||
destroy() {
|
||||
clear();
|
||||
destroyed = true;
|
||||
},
|
||||
get collection() {
|
||||
return collection;
|
||||
},
|
||||
get size() {
|
||||
return entries.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { wildlifeGridCellBounds } from './gridViewport.js';
|
||||
import {
|
||||
createWildlifeDensity,
|
||||
wildlifeDensityIntensity,
|
||||
wildlifeDensityRanks,
|
||||
wildlifeDensityKernel,
|
||||
WILDLIFE_DENSITY_MAX_ALPHA,
|
||||
WILDLIFE_DENSITY_NEAR_FADE,
|
||||
WILDLIFE_DENSITY_OVERLAP,
|
||||
} from './density.js';
|
||||
import { WILDLIFE_WORK_UNIT } from './performance.js';
|
||||
|
||||
function cell(column, count = 1, options = {}) {
|
||||
const id = `grid:3/4/2:31:${column * 2 + 1}`;
|
||||
const bounds = wildlifeGridCellBounds(id);
|
||||
return {
|
||||
id,
|
||||
lat: (bounds.south + bounds.north) / 2,
|
||||
lon: (bounds.west + bounds.east) / 2,
|
||||
cellCount: count,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
function harness({ documentStyle = 'normal', camera = {} } = {}) {
|
||||
const counts = { positions: 0, writes: 0, created: 0, updates: 0 };
|
||||
class BillboardCollection extends Cesium.BillboardCollection {
|
||||
add(options) {
|
||||
counts.created++;
|
||||
const point = super.add(options);
|
||||
return new Proxy(point, {
|
||||
get: (target, key) => Reflect.get(target, key, target),
|
||||
set(target, key, value) {
|
||||
counts.writes++;
|
||||
return Reflect.set(target, key, value, target);
|
||||
},
|
||||
});
|
||||
}
|
||||
update() {
|
||||
counts.updates++;
|
||||
}
|
||||
}
|
||||
const primitives = new Cesium.PrimitiveCollection();
|
||||
let handle = 0;
|
||||
const timers = new Map();
|
||||
const idle = new Map();
|
||||
const renders = [];
|
||||
const listeners = new Map();
|
||||
const eventTarget = {
|
||||
addEventListener(name, listener) {
|
||||
if (!listeners.has(name)) listeners.set(name, new Set());
|
||||
listeners.get(name).add(listener);
|
||||
},
|
||||
removeEventListener(name, listener) {
|
||||
listeners.get(name)?.delete(listener);
|
||||
},
|
||||
emit(name, detail) {
|
||||
for (const listener of listeners.get(name) || []) listener({ detail });
|
||||
},
|
||||
};
|
||||
const density = createWildlifeDensity({
|
||||
cesium: { ...Cesium, BillboardCollection },
|
||||
viewer: { scene: { primitives }, camera },
|
||||
positionForCell(row) {
|
||||
counts.positions++;
|
||||
return Cesium.Cartesian3.fromDegrees(row.lon, row.lat);
|
||||
},
|
||||
requestRender: (reason) => renders.push(reason),
|
||||
setTimer(fn) {
|
||||
timers.set(++handle, fn);
|
||||
return handle;
|
||||
},
|
||||
clearTimer: (id) => timers.delete(id),
|
||||
requestIdle(fn) {
|
||||
idle.set(++handle, fn);
|
||||
return handle;
|
||||
},
|
||||
cancelIdle: (id) => idle.delete(id),
|
||||
eventTarget,
|
||||
documentRef: { documentElement: { dataset: { gevStyle: documentStyle } } },
|
||||
});
|
||||
function flushIdle() {
|
||||
while (idle.size) {
|
||||
const [id, fn] = idle.entries().next().value;
|
||||
idle.delete(id);
|
||||
fn();
|
||||
}
|
||||
}
|
||||
function fadeStep() {
|
||||
for (const [id, fn] of [...timers]) {
|
||||
timers.delete(id);
|
||||
fn();
|
||||
}
|
||||
flushIdle();
|
||||
}
|
||||
return {
|
||||
density,
|
||||
primitives,
|
||||
counts,
|
||||
renders,
|
||||
timers,
|
||||
idle,
|
||||
listeners,
|
||||
eventTarget,
|
||||
flushIdle,
|
||||
fadeStep,
|
||||
settle() {
|
||||
while (timers.size || idle.size) {
|
||||
fadeStep();
|
||||
flushIdle();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('density intensity rises with view rank, is bounded, and leaves no background for zero data', () => {
|
||||
for (const empty of [0, -1, null, undefined, NaN, Infinity])
|
||||
assert.equal(wildlifeDensityIntensity(empty), 0);
|
||||
const ranks = [0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 1];
|
||||
const values = ranks.map(wildlifeDensityIntensity);
|
||||
assert.ok(values[0] > 0, 'the sparsest cell still leaves a trace');
|
||||
assert.ok(values[0] < 0.02, 'sparse cells stay a trace, not a haze');
|
||||
for (let i = 1; i < values.length; i++) assert.ok(values[i] > values[i - 1]);
|
||||
assert.equal(values.at(-1), WILDLIFE_DENSITY_MAX_ALPHA);
|
||||
assert.ok(
|
||||
values[3] < WILDLIFE_DENSITY_MAX_ALPHA / 3,
|
||||
'a median cell is well below a hotspot',
|
||||
);
|
||||
});
|
||||
|
||||
test('density ranks are relative to the view, so hotspots stay distinct at any zoom', () => {
|
||||
const cells = (counts) => counts.map((cellCount) => ({ cellCount }));
|
||||
const near = wildlifeDensityRanks(cells([1, 2, 3, 40]));
|
||||
const far = wildlifeDensityRanks(cells([100, 200, 300, 4000]));
|
||||
assert.equal(near(40), 1);
|
||||
assert.equal(far(4000), 1, 'the top cell is brightest whatever its count');
|
||||
assert.equal(near(1), far(100), 'the same position ranks the same');
|
||||
assert.equal(wildlifeDensityRanks(cells([0, -1]))(5), 0);
|
||||
assert.equal(wildlifeDensityRanks([])(5), 0);
|
||||
});
|
||||
|
||||
test('an empty density snapshot creates no glow', () => {
|
||||
const h = harness();
|
||||
assert.deepEqual(h.density.update([]), {
|
||||
sightingCount: 0,
|
||||
changed: false,
|
||||
size: 0,
|
||||
});
|
||||
h.density.update([
|
||||
cell(0, 0),
|
||||
cell(1, -1),
|
||||
cell(2, 0.5),
|
||||
cell(3, 1, { id: 'invalid' }),
|
||||
]);
|
||||
assert.equal(h.density.collection, null);
|
||||
assert.equal(h.primitives.length, 0);
|
||||
assert.equal(h.timers.size, 0);
|
||||
h.density.destroy();
|
||||
});
|
||||
|
||||
test('uniform occupied cells make a continuous field with no visible lattice', () => {
|
||||
assert.ok(WILDLIFE_DENSITY_OVERLAP >= 1.5);
|
||||
assert.equal(wildlifeDensityKernel(0), 1);
|
||||
assert.equal(wildlifeDensityKernel(WILDLIFE_DENSITY_OVERLAP / 2), 0);
|
||||
const samples = [];
|
||||
for (let x = 0; x < 1; x += 0.1)
|
||||
for (let y = 0; y < 1; y += 0.1) {
|
||||
let value = 0;
|
||||
for (let dx = -3; dx <= 3; dx++)
|
||||
for (let dy = -3; dy <= 3; dy++)
|
||||
value += wildlifeDensityKernel(Math.hypot(x - dx, y - dy));
|
||||
samples.push(value);
|
||||
}
|
||||
const modulation =
|
||||
(Math.max(...samples) - Math.min(...samples)) / Math.max(...samples);
|
||||
assert.ok(modulation < 0.007, `${(100 * modulation).toFixed(3)}% modulation`);
|
||||
});
|
||||
|
||||
test('each glow kernel fades on the GPU as the camera comes inside it, scaled to its own width', () => {
|
||||
const h = harness();
|
||||
h.density.update([cell(0, 4), cell(1, 9)]);
|
||||
const collection = h.density.collection;
|
||||
assert.equal(collection.length, 2);
|
||||
for (let i = 0; i < collection.length; i++) {
|
||||
const point = collection.get(i);
|
||||
const width = point.scale * point.width;
|
||||
const fade = point.translucencyByDistance;
|
||||
assert.ok(fade, 'no screen-filling kernel when zoomed inside it');
|
||||
assert.ok(
|
||||
Math.abs(fade.near - width * WILDLIFE_DENSITY_NEAR_FADE.near) < 1e-6,
|
||||
);
|
||||
assert.equal(fade.nearValue, 0);
|
||||
assert.ok(
|
||||
Math.abs(fade.far - width * WILDLIFE_DENSITY_NEAR_FADE.far) < 1e-6,
|
||||
);
|
||||
assert.equal(fade.farValue, 1);
|
||||
}
|
||||
h.density.destroy();
|
||||
});
|
||||
|
||||
test('occupied grid counts produce unlabelled world-sized glow billboards that never enter Cesium picking', () => {
|
||||
const h = harness();
|
||||
const rows = [cell(0, 1), cell(1, 100), cell(2, 1000000)];
|
||||
const result = h.density.update(rows);
|
||||
assert.equal(result.sightingCount, 1000101);
|
||||
assert.equal(h.primitives.length, 1);
|
||||
const collection = h.density.collection;
|
||||
assert.equal(collection.allowPicking, false);
|
||||
assert.equal(collection.length, 3);
|
||||
const first = collection.get(0);
|
||||
assert.equal(
|
||||
first.id,
|
||||
undefined,
|
||||
'density has no sighting identity or pick payload',
|
||||
);
|
||||
assert.equal(first.sizeInMeters, true);
|
||||
assert.equal(first.scaleByDistance, undefined);
|
||||
assert.match(decodeURIComponent(first.image), /radialGradient/);
|
||||
assert.doesNotMatch(decodeURIComponent(first.image), /text|badge|raven/);
|
||||
const bounds = wildlifeGridCellBounds(rows[0].id);
|
||||
const logicalHeight =
|
||||
(((bounds.north - bounds.south) * Math.PI) / 180) * 6378137;
|
||||
assert.ok(first.width * first.scale >= 1.5 * logicalHeight);
|
||||
assert.equal(first.width, first.height);
|
||||
assert.ok(
|
||||
first.width <= 65536,
|
||||
'encoded dimensions fit Cesium width packing',
|
||||
);
|
||||
assert.ok(
|
||||
first.width * first.scale > 65536,
|
||||
'continental splats retain their true footprint through scale',
|
||||
);
|
||||
collection.update({ passes: { pick: true } });
|
||||
assert.equal(
|
||||
h.counts.updates,
|
||||
0,
|
||||
'the real public pick update is suppressed',
|
||||
);
|
||||
collection.update({ passes: { render: true } });
|
||||
assert.equal(h.counts.updates, 1);
|
||||
h.settle();
|
||||
const rankOf = wildlifeDensityRanks(rows);
|
||||
for (let i = 0; i < collection.length; i++)
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
collection.get(i).color.alpha -
|
||||
wildlifeDensityIntensity(rankOf(rows[i].cellCount)),
|
||||
) < 1e-12,
|
||||
);
|
||||
h.density.destroy();
|
||||
});
|
||||
|
||||
test('progressive tile arrival fades only new cells and reuses existing glow without flashing', () => {
|
||||
const h = harness();
|
||||
const one = cell(0, 4);
|
||||
const two = cell(1, 10);
|
||||
h.density.update([one]);
|
||||
const first = h.density.collection.get(0);
|
||||
const starting = first.color.alpha;
|
||||
h.fadeStep();
|
||||
assert.ok(first.color.alpha > starting);
|
||||
const middle = first.color.alpha;
|
||||
h.density.update([one, two]);
|
||||
// The arriving tile re-ranks the incumbent (alone it was the hotspot; now
|
||||
// it is the median) but its fade progress is kept, not restarted.
|
||||
const progress = middle / wildlifeDensityIntensity(1);
|
||||
assert.ok(
|
||||
Math.abs(first.color.alpha - wildlifeDensityIntensity(0.5) * progress) <
|
||||
1e-12,
|
||||
'a streamed tile re-ranks but does not restart incumbents',
|
||||
);
|
||||
assert.equal(h.density.collection.get(0), first);
|
||||
assert.equal(h.density.size, 2);
|
||||
h.settle();
|
||||
const settled = first.color.alpha;
|
||||
h.density.update([one, two]);
|
||||
assert.equal(first.color.alpha, settled);
|
||||
h.density.update([two]);
|
||||
assert.equal(h.density.size, 1);
|
||||
assert.equal(h.density.collection.length, 1);
|
||||
h.density.update([]);
|
||||
assert.equal(h.density.size, 0);
|
||||
assert.equal(
|
||||
h.density.collection,
|
||||
null,
|
||||
'empty data releases the atlas and collection',
|
||||
);
|
||||
assert.equal(
|
||||
h.primitives.length,
|
||||
0,
|
||||
'no full-view fill remains over empty data',
|
||||
);
|
||||
h.density.destroy();
|
||||
});
|
||||
|
||||
test('visibility and antimeridian padding are applied on data/moveEnd work only', () => {
|
||||
const h = harness();
|
||||
const west = cell(1);
|
||||
const east = cell(28);
|
||||
const box = { ...wildlifeGridCellBounds(west.id) };
|
||||
assert.equal(h.density.update([west, east], { boxes: [box] }).size, 1);
|
||||
assert.equal(
|
||||
h.density.update([west], { boxes: [box], isPointVisible: () => false })
|
||||
.size,
|
||||
0,
|
||||
);
|
||||
const id = 'grid:3/0/2:31:1';
|
||||
const bounds = wildlifeGridCellBounds(id);
|
||||
const wrapped = {
|
||||
...west,
|
||||
id,
|
||||
lat: (bounds.south + bounds.north) / 2,
|
||||
lon: -179.3,
|
||||
};
|
||||
const crossed = {
|
||||
south: bounds.south,
|
||||
north: bounds.north,
|
||||
west: 179,
|
||||
east: -179,
|
||||
};
|
||||
assert.equal(h.density.update([wrapped], { boxes: [crossed] }).size, 1);
|
||||
h.density.destroy();
|
||||
});
|
||||
|
||||
test('density culls far-side cells and front-side kernels that would spill over the limb', () => {
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 5_000_000),
|
||||
positionCartographic: { height: 5_000_000 },
|
||||
};
|
||||
const h = harness({ camera });
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
camera.positionWC,
|
||||
);
|
||||
const cells = [
|
||||
cell(0, 1, { lat: 0, lon: 0 }),
|
||||
cell(1, 2, { lat: 0, lon: 54 }),
|
||||
cell(2, 4, { lat: 0, lon: 110 }),
|
||||
];
|
||||
assert.equal(
|
||||
occluder.isPointVisible(Cesium.Cartesian3.fromDegrees(54, 0)),
|
||||
true,
|
||||
'the limb fixture has a visible centre but an overflowing footprint',
|
||||
);
|
||||
const result = h.density.update(cells, {
|
||||
isPointVisible: (anchor) => occluder.isPointVisible(anchor),
|
||||
});
|
||||
assert.equal(result.size, 1, 'only the fully front-side kernel can glow');
|
||||
assert.equal(
|
||||
result.sightingCount,
|
||||
3,
|
||||
'visual limb padding never removes visible counts',
|
||||
);
|
||||
h.density.destroy();
|
||||
});
|
||||
|
||||
test('density fades from regional detail to a clear whole-globe view without frame work or count loss', () => {
|
||||
const camera = { positionCartographic: { height: 3_000_000 } };
|
||||
const h = harness({ camera });
|
||||
const cells = [cell(0, 12)];
|
||||
h.density.update(cells);
|
||||
h.settle();
|
||||
const point = h.density.collection.get(0);
|
||||
const fullAlpha = point.color.alpha;
|
||||
camera.positionCartographic.height = 5_500_000;
|
||||
h.density.update(cells);
|
||||
assert.equal(h.density.collection.get(0), point);
|
||||
assert.ok(Math.abs(point.color.alpha - fullAlpha / 2) < 1e-12);
|
||||
const writes = h.counts.writes;
|
||||
h.density.update(cells);
|
||||
assert.equal(h.counts.writes, writes, 'unchanged height writes nothing');
|
||||
camera.positionCartographic.height = 8_000_000;
|
||||
assert.equal(h.density.update(cells).sightingCount, 12);
|
||||
assert.equal(h.density.collection, null);
|
||||
assert.equal(h.timers.size, 0, 'no invisible fade or frame loop');
|
||||
const positions = h.counts.positions;
|
||||
camera.positionCartographic.height = 3_000_000;
|
||||
h.density.update(cells);
|
||||
h.settle();
|
||||
assert.equal(
|
||||
h.counts.positions,
|
||||
positions,
|
||||
'altitude changes reuse decoded positions',
|
||||
);
|
||||
assert.equal(h.density.collection.get(0).color.alpha, fullAlpha);
|
||||
h.density.destroy();
|
||||
});
|
||||
|
||||
test('styles follow persisted mono/CRT and cockpit vision overrides, without adding a second ramp', () => {
|
||||
const h = harness({ documentStyle: 'thermal' });
|
||||
h.density.update([cell(0)]);
|
||||
h.settle();
|
||||
const point = h.density.collection.get(0);
|
||||
assert.equal(point.color.red, point.color.green);
|
||||
assert.equal(point.color.green, point.color.blue);
|
||||
const alpha = point.color.alpha;
|
||||
h.eventTarget.emit('gev:style-change', { style: 'retro' });
|
||||
h.flushIdle();
|
||||
assert.ok(point.color.green > point.color.red);
|
||||
h.eventTarget.emit('gev:vision-change', { cockpit: true, style: 'nvg' });
|
||||
h.flushIdle();
|
||||
assert.equal(point.color.red, point.color.green);
|
||||
h.eventTarget.emit('gev:style-change', { style: 'normal' });
|
||||
h.flushIdle();
|
||||
assert.equal(
|
||||
point.color.red,
|
||||
point.color.green,
|
||||
'cockpit vision wins while active',
|
||||
);
|
||||
h.eventTarget.emit('gev:vision-change', { cockpit: false, style: 'normal' });
|
||||
h.flushIdle();
|
||||
assert.notEqual(point.color.red, point.color.green);
|
||||
assert.equal(point.color.alpha, alpha);
|
||||
h.density.destroy();
|
||||
});
|
||||
|
||||
test('clear/disable and destroy release the atlas, primitives, coordinates, timers and listeners', () => {
|
||||
const h = harness();
|
||||
const row = cell(0);
|
||||
h.density.update([row]);
|
||||
const old = h.density.collection;
|
||||
assert.equal(h.timers.size, 1);
|
||||
h.fadeStep();
|
||||
h.density.clear();
|
||||
assert.equal(old.isDestroyed(), true);
|
||||
assert.equal(h.density.collection, null);
|
||||
assert.equal(h.density.size, 0);
|
||||
assert.equal(h.primitives.length, 0);
|
||||
assert.equal(h.timers.size, 0);
|
||||
assert.equal(h.idle.size, 0);
|
||||
for (const values of h.listeners.values()) assert.equal(values.size, 0);
|
||||
const priorPositions = h.counts.positions;
|
||||
h.density.update([row]);
|
||||
assert.equal(
|
||||
h.counts.positions,
|
||||
priorPositions + 1,
|
||||
'disable released cached coordinates',
|
||||
);
|
||||
const work = h.density.updateWork([cell(2)]);
|
||||
work.next();
|
||||
h.density.destroy();
|
||||
assert.equal(
|
||||
work.next().done,
|
||||
true,
|
||||
'obsolete data work cannot resurrect a destroyed layer',
|
||||
);
|
||||
assert.equal(h.density.collection, null);
|
||||
assert.equal(h.primitives.length, 0);
|
||||
assert.equal(h.timers.size, 0);
|
||||
assert.equal(h.idle.size, 0);
|
||||
});
|
||||
|
||||
test('dense six-tile updates have a deterministic linear operation budget and no warmed writes/projections', (t) => {
|
||||
const h = harness();
|
||||
const cells = [];
|
||||
for (let x = 1; x < 3; x++)
|
||||
for (let y = 1; y < 4; y++)
|
||||
for (let row = 1; row < 64; row += 2)
|
||||
for (let column = 1; column < 64; column += 2) {
|
||||
const id = `grid:3/${x}/${y}:${row}:${column}`;
|
||||
const box = wildlifeGridCellBounds(id);
|
||||
cells.push({
|
||||
id,
|
||||
lat: (box.south + box.north) / 2,
|
||||
lon: (box.west + box.east) / 2,
|
||||
cellCount: row * 64 + column,
|
||||
});
|
||||
}
|
||||
let operations;
|
||||
h.density.update(cells, {
|
||||
onOperations: (value) => {
|
||||
operations = value;
|
||||
},
|
||||
});
|
||||
h.settle();
|
||||
assert.equal(operations.densityVisits, 6144);
|
||||
assert.equal(operations.densityMetadataBuilds, 6144);
|
||||
assert.equal(operations.densityPositionBuilds, 6144);
|
||||
assert.equal(operations.densityCreated, 6144);
|
||||
const positions = h.counts.positions;
|
||||
const writes = h.counts.writes;
|
||||
let units = 0;
|
||||
const work = h.density.updateWork(cells, {
|
||||
onOperations: (value) => {
|
||||
operations = value;
|
||||
},
|
||||
});
|
||||
while (!work.next().done) units++;
|
||||
assert.deepEqual(operations, {
|
||||
densityVisits: 6144,
|
||||
densityRetirementVisits: 6144,
|
||||
densityMetadataBuilds: 0,
|
||||
densityPositionBuilds: 0,
|
||||
densityWrites: 0,
|
||||
densityCreated: 0,
|
||||
densityRemoved: 0,
|
||||
});
|
||||
assert.equal(units, Math.ceil(6144 / WILDLIFE_WORK_UNIT) * 2);
|
||||
assert.equal(h.counts.positions, positions);
|
||||
assert.equal(h.counts.writes, writes);
|
||||
t.diagnostic(
|
||||
`6,144 occupied cells: ${units} bounded units, 12,288 linear visits, zero warmed metadata/Cartesian builds, projections or billboard writes`,
|
||||
);
|
||||
h.density.destroy();
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { VOICE_OFF_LAYERS } from '../../voice/layerManifest.js';
|
||||
import { ANALYST_LAYERS } from '../../data/analystEngine.js';
|
||||
|
||||
const read = (path) =>
|
||||
readFileSync(new URL(`../../../${path}`, import.meta.url), 'utf8');
|
||||
const current = read('docs/CURRENT-STATE.md');
|
||||
const start = current.indexOf('## Wildlife Sightings —');
|
||||
const section = current
|
||||
.slice(start, current.indexOf('\n## ', start + 1))
|
||||
.replace(/\s+/g, ' ');
|
||||
|
||||
test('Wildlife docs name the permanent token, voice policy and coverage limits', () => {
|
||||
const token = JSON.parse(
|
||||
read('src/data/layerStateTokenReservations.json'),
|
||||
).find(([id]) => id === 'wildlife-sightings')?.[1];
|
||||
assert.ok(token);
|
||||
assert.match(section, new RegExp(`share-link token \`${token}\``));
|
||||
assert.match(section, /Voice is off for this layer for now/);
|
||||
assert.ok(Object.hasOwn(VOICE_OFF_LAYERS, 'wildlife-sightings'));
|
||||
assert.ok(!Object.hasOwn(ANALYST_LAYERS, 'wildlife-sightings'));
|
||||
assert.match(section, /not a census/i);
|
||||
assert.match(section, /Salish Sea/);
|
||||
assert.match(section, /0\.01°/);
|
||||
assert.match(section, /private records are excluded/);
|
||||
});
|
||||
|
||||
test('Wildlife documentation defines recency by observation time and compact ambient credit', () => {
|
||||
assert.match(section, /observation time/i);
|
||||
assert.match(section, /\bd1\b/);
|
||||
assert.match(section, /48 h/);
|
||||
assert.match(section, /96 px/);
|
||||
assert.match(section, /creator name/i);
|
||||
assert.match(section, /110\/125 km/);
|
||||
assert.match(section, /120 km/);
|
||||
assert.match(section, /random sample/);
|
||||
assert.match(section, /bounded frame cache/);
|
||||
assert.match(read('DATA_SOURCES.md'), /observation time/i);
|
||||
});
|
||||
|
||||
test('Wildlife docs describe direct photo selection and text-only privacy rows', () => {
|
||||
assert.match(section, /compact anchored\s+photo card/);
|
||||
assert.match(section, /object-fit: contain/);
|
||||
assert.match(section, /preserving zoom/);
|
||||
assert.match(section, /compact observed\s+age/);
|
||||
assert.match(section, /Obscured sightings also stay\s+text-only/);
|
||||
assert.match(section, /Acartia stays text-only/);
|
||||
assert.match(section, /closes the card and clears selection/);
|
||||
assert.match(
|
||||
section,
|
||||
/shell teardown also removes its host and subscription/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
section,
|
||||
/opens\s+the \*\*Sighting\*\* panel|Clicking the panel photo/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Wildlife docs record queue failure isolation and the complete badge mapping', () => {
|
||||
assert.match(section, /recovered queue tail/);
|
||||
assert.match(section, /ordinary grid failures/);
|
||||
assert.match(section, /DEGRADED/);
|
||||
assert.match(section, /1.5 px dark outline/);
|
||||
for (const glyph of [
|
||||
'raven',
|
||||
'pets',
|
||||
'egg_alt',
|
||||
'water_drop',
|
||||
'set_meal',
|
||||
'bug_report',
|
||||
'pest_control',
|
||||
'eco',
|
||||
'umbrella',
|
||||
'category',
|
||||
'question_mark',
|
||||
])
|
||||
assert.ok(section.includes('`' + glyph + '`'), glyph);
|
||||
});
|
||||
|
||||
test('Wildlife docs separate density from real observation icons and define persistent selection', () => {
|
||||
assert.match(section, /dedicated, nonpickable billboard/);
|
||||
assert.match(section, /view-relative density ramp/);
|
||||
assert.match(section, /four logical cell widths/);
|
||||
assert.match(
|
||||
section,
|
||||
/Only real sampled observation rows produce taxon badges/,
|
||||
);
|
||||
assert.match(
|
||||
section,
|
||||
/Grid tiles publish even while the independent observation/,
|
||||
);
|
||||
assert.match(section, /until explicit\s+deselection or layer disable/);
|
||||
assert.match(
|
||||
section,
|
||||
/releases\s+the glow collection, texture atlas, timers, queues and style listeners/,
|
||||
);
|
||||
assert.doesNotMatch(section, /grid badge|Clicking a grid cell/);
|
||||
});
|
||||
|
||||
test('Wildlife docs describe one source link, name declutter and globe glow limits', () => {
|
||||
assert.match(section, /selected chrome and paint lane/);
|
||||
assert.match(section, /single source link/);
|
||||
assert.match(section, /80 screen pixels/);
|
||||
assert.match(section, /word boundary/);
|
||||
assert.match(section, /3,000 km/);
|
||||
assert.match(section, /8,000 km/);
|
||||
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/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
// Observation icons share 28px screen buckets. The density glow uses finer
|
||||
// 8–14px logical cells, coarsened until the complete view fits the tile budget.
|
||||
export const WILDLIFE_GRID_TARGET_SPACING_PX = 28;
|
||||
export const WILDLIFE_GRID_SAMPLE_SPACING_PX = 14;
|
||||
export const WILDLIFE_GRID_TILE_CAP = 6;
|
||||
export const WILDLIFE_GRID_MAX_ZOOM = 22;
|
||||
// The 64×64 UTFGrid is a hit mask: each logical 8px observation bin
|
||||
// occupies 2×2 of its 4px pixels, plus a dilated hit outline. Returned centres
|
||||
// are at odd pixel coordinates. Count logical bins once, not hit-mask pixels.
|
||||
export const WILDLIFE_GRID_LOGICAL_SIZE = 32;
|
||||
const MAX_LATITUDE = 85.0511287798066;
|
||||
const degrees = (radians) => (radians * 180) / Math.PI;
|
||||
const latitudeY = (latitude) => {
|
||||
const radians =
|
||||
(Math.max(-MAX_LATITUDE, Math.min(MAX_LATITUDE, latitude)) * Math.PI) / 180;
|
||||
return (1 - Math.asinh(Math.tan(radians)) / Math.PI) / 2;
|
||||
};
|
||||
const yLatitude = (value) =>
|
||||
degrees(Math.atan(Math.sinh(Math.PI * (1 - 2 * value))));
|
||||
const validBox = (box) =>
|
||||
!!box &&
|
||||
['south', 'west', 'north', 'east'].every((key) =>
|
||||
Number.isFinite(box[key]),
|
||||
) &&
|
||||
box.south >= -90 &&
|
||||
box.north <= 90 &&
|
||||
box.north > box.south &&
|
||||
box.west >= -180 &&
|
||||
box.west <= 180 &&
|
||||
box.east >= -180 &&
|
||||
box.east <= 180 &&
|
||||
box.west !== box.east;
|
||||
|
||||
function normalizedBoxes(boxes) {
|
||||
if (!Array.isArray(boxes) || !boxes.length || !boxes.every(validBox))
|
||||
return [];
|
||||
return boxes
|
||||
.flatMap((box) =>
|
||||
box.west < box.east
|
||||
? [box]
|
||||
: [
|
||||
{ ...box, east: 180 },
|
||||
{ ...box, west: -180 },
|
||||
],
|
||||
)
|
||||
.filter((box) => box.east > box.west);
|
||||
}
|
||||
|
||||
function tileRanges(boxes, z) {
|
||||
const count = 2 ** z;
|
||||
const index = (value) => Math.max(0, Math.min(count - 1, Math.floor(value)));
|
||||
return normalizedBoxes(boxes).map((box) => ({
|
||||
left: index(((box.west + 180) / 360) * count),
|
||||
right: index(((box.east + 180) / 360) * count - 1e-9),
|
||||
top: index(latitudeY(box.north) * count),
|
||||
bottom: index(latitudeY(box.south) * count - 1e-9),
|
||||
}));
|
||||
}
|
||||
|
||||
function tileCount(boxes, z) {
|
||||
// Split antimeridian ranges cannot overlap except at z=0. Counting that
|
||||
// shared tile twice merely makes the request cap slightly conservative.
|
||||
return tileRanges(boxes, z).reduce(
|
||||
(sum, range) =>
|
||||
sum + (range.right - range.left + 1) * (range.bottom - range.top + 1),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/** Choose the finest glow spacing whose full viewport fits the tile budget. */
|
||||
export function chooseWildlifeGridZoom({ boxes, width, height } = {}) {
|
||||
const visible = normalizedBoxes(boxes);
|
||||
if (!visible.length || !(width > 0) || !(height > 0)) return 0;
|
||||
const horizontalSpan = Math.min(
|
||||
1,
|
||||
visible.reduce((sum, box) => sum + (box.east - box.west) / 360, 0),
|
||||
);
|
||||
const verticalSpan = Math.max(
|
||||
...visible.map((box) => latitudeY(box.south) - latitudeY(box.north)),
|
||||
);
|
||||
const pixelsPerWorld = Math.min(
|
||||
width / horizontalSpan,
|
||||
height / Math.max(1e-9, verticalSpan),
|
||||
);
|
||||
let z = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
WILDLIFE_GRID_MAX_ZOOM,
|
||||
Math.ceil(
|
||||
Math.log2(
|
||||
pixelsPerWorld /
|
||||
(WILDLIFE_GRID_LOGICAL_SIZE * WILDLIFE_GRID_SAMPLE_SPACING_PX),
|
||||
) - 1e-9,
|
||||
),
|
||||
),
|
||||
);
|
||||
while (z > 0 && tileCount(visible, z) > WILDLIFE_GRID_TILE_CAP) z -= 1;
|
||||
return z;
|
||||
}
|
||||
|
||||
/** Only intersecting tiles, in stable XYZ order, with no neighboring padding.
|
||||
* Direct oversized requests fail closed; zoom selection coarsens beforehand. */
|
||||
export function wildlifeGridTiles({ boxes, z } = {}) {
|
||||
if (!Number.isInteger(z) || z < 0 || z > WILDLIFE_GRID_MAX_ZOOM) return [];
|
||||
if (tileCount(boxes, z) > WILDLIFE_GRID_TILE_CAP) return [];
|
||||
const tiles = new Map();
|
||||
for (const range of tileRanges(boxes, z)) {
|
||||
for (let x = range.left; x <= range.right; x += 1) {
|
||||
for (let y = range.top; y <= range.bottom; y += 1) {
|
||||
tiles.set(`${x}/${y}`, { z, x, y });
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...tiles.values()].sort((a, b) => a.x - b.x || a.y - b.y);
|
||||
}
|
||||
|
||||
/** Unlike detail-row viewport queries, grids cover full globe views too. */
|
||||
export function wildlifeGridViewportBoxes(viewer) {
|
||||
const rectangle = viewer?.camera?.computeViewRectangle?.(
|
||||
viewer?.scene?.globe?.ellipsoid,
|
||||
);
|
||||
if (!rectangle) return [];
|
||||
return normalizedBoxes([
|
||||
Object.fromEntries(
|
||||
['south', 'west', 'north', 'east'].map((key) => [
|
||||
key,
|
||||
Math.round(degrees(rectangle[key]) * 1e8) / 1e8,
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Return the geographic footprint of one logical occupied cell. */
|
||||
export function wildlifeGridCellBounds(id) {
|
||||
const match = /^grid:(\d+)\/(\d+)\/(\d+):(\d+):(\d+)$/.exec(id);
|
||||
if (!match) return null;
|
||||
const [z, x, y, row, column] = match.slice(1).map(Number);
|
||||
const count = 2 ** z;
|
||||
if (
|
||||
z > WILDLIFE_GRID_MAX_ZOOM ||
|
||||
x >= count ||
|
||||
y >= count ||
|
||||
row >= 64 ||
|
||||
column >= 64
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
south: yLatitude((y + (Math.floor(row / 2) + 1) / 32) / count),
|
||||
west: ((x + Math.floor(column / 2) / 32) / count) * 360 - 180,
|
||||
north: yLatitude((y + Math.floor(row / 2) / 32) / count),
|
||||
east: ((x + (Math.floor(column / 2) + 1) / 32) / count) * 360 - 180,
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared with the proxy and cached-coverage matching. A tile's hit area can
|
||||
* bleed into its neighbor; the geographic owner makes ids stable. */
|
||||
export function wildlifeGridCellId(lat, lon, z) {
|
||||
if (
|
||||
!Number.isFinite(lat) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
!Number.isFinite(lon) ||
|
||||
Math.abs(lon) > 180 ||
|
||||
!Number.isInteger(z) ||
|
||||
z < 0 ||
|
||||
z > WILDLIFE_GRID_MAX_ZOOM
|
||||
)
|
||||
return null;
|
||||
const count = 2 ** z;
|
||||
const worldX = ((((lon + 180) / 360) % 1) + 1) % 1;
|
||||
const worldY = Math.max(0, Math.min(1 - Number.EPSILON, latitudeY(lat)));
|
||||
const tileX = Math.min(count - 1, Math.floor(worldX * count));
|
||||
const tileY = Math.min(count - 1, Math.floor(worldY * count));
|
||||
const column =
|
||||
Math.min(31, Math.floor((worldX * count - tileX) * 32 + 1e-8)) * 2 + 1;
|
||||
const row =
|
||||
Math.min(31, Math.floor((worldY * count - tileY) * 32 + 1e-8)) * 2 + 1;
|
||||
return `grid:${z}/${tileX}/${tileY}:${row}:${column}`;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
chooseWildlifeGridZoom,
|
||||
wildlifeGridTiles,
|
||||
wildlifeGridViewportBoxes,
|
||||
wildlifeGridCellBounds,
|
||||
wildlifeGridCellId,
|
||||
WILDLIFE_GRID_LOGICAL_SIZE,
|
||||
WILDLIFE_GRID_TILE_CAP,
|
||||
} from './gridViewport.js';
|
||||
|
||||
const globe = [
|
||||
{ south: -85.0511287798066, west: -180, north: 85.0511287798066, east: 180 },
|
||||
];
|
||||
const regional = [{ south: 0, west: 0, north: 40.979898069620134, east: 45 }];
|
||||
|
||||
test('glow sampling uses roughly 8–14px cells independently of the 28px icon buckets', () => {
|
||||
for (const [boxes, width, height, expected] of [
|
||||
[globe, 448, 448, 0],
|
||||
[regional, 448, 448, 3],
|
||||
[
|
||||
[{ south: 0, west: 0, north: 0.08789059053082508, east: 0.087890625 }],
|
||||
448,
|
||||
448,
|
||||
12,
|
||||
],
|
||||
]) {
|
||||
const zoom = chooseWildlifeGridZoom({ boxes, width, height });
|
||||
assert.equal(zoom, expected);
|
||||
const worldPixels = (width * 360) / (boxes[0].east - boxes[0].west);
|
||||
const spacing = worldPixels / 2 ** zoom / WILDLIFE_GRID_LOGICAL_SIZE;
|
||||
assert.ok(spacing >= 8 && spacing <= 14, `${zoom}: ${spacing}px`);
|
||||
assert.ok(
|
||||
wildlifeGridTiles({ boxes, z: zoom }).length <= WILDLIFE_GRID_TILE_CAP,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the named six-tile budget preserves complete coverage for common views', () => {
|
||||
for (const [boxes, width, height, expected] of [
|
||||
[globe, 896, 896, 1],
|
||||
[regional, 896, 896, 4],
|
||||
[[{ south: 7, west: -168, north: 72, east: -48 }], 1400, 900, 2],
|
||||
[globe, 4000, 4000, 1],
|
||||
[globe, 30_000, 400, 0],
|
||||
]) {
|
||||
const z = chooseWildlifeGridZoom({ boxes, width, height });
|
||||
assert.equal(z, expected);
|
||||
const tiles = wildlifeGridTiles({ boxes, z });
|
||||
assert.ok(tiles.length > 0 && tiles.length <= WILDLIFE_GRID_TILE_CAP);
|
||||
}
|
||||
});
|
||||
|
||||
test('tile ranges contain only intersecting tiles, no offscreen neighbors or duplicate boundary tiles', () => {
|
||||
assert.deepEqual(wildlifeGridTiles({ boxes: regional, z: 3 }), [
|
||||
{ z: 3, x: 4, y: 3 },
|
||||
]);
|
||||
assert.deepEqual(wildlifeGridTiles({ boxes: globe, z: 0 }), [
|
||||
{ z: 0, x: 0, y: 0 },
|
||||
]);
|
||||
const crossing = [{ south: 10, west: 179, north: 12, east: -178 }];
|
||||
const split = [
|
||||
{ south: 10, west: 179, north: 12, east: 180 },
|
||||
{ south: 10, west: -180, north: 12, east: -178 },
|
||||
];
|
||||
const z = chooseWildlifeGridZoom({
|
||||
boxes: crossing,
|
||||
width: 896,
|
||||
height: 600,
|
||||
});
|
||||
assert.deepEqual(
|
||||
wildlifeGridTiles({ boxes: crossing, z }),
|
||||
wildlifeGridTiles({ boxes: split, z }),
|
||||
);
|
||||
assert.ok(
|
||||
wildlifeGridTiles({ boxes: crossing, z }).every(
|
||||
(tile) =>
|
||||
((tile.x + 1) / 2 ** z) * 360 - 180 > 179 ||
|
||||
(tile.x / 2 ** z) * 360 - 180 < -178,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('tile cap degrades zoom to preserve complete coverage and oversized direct requests fail closed', () => {
|
||||
const z = chooseWildlifeGridZoom({
|
||||
boxes: globe,
|
||||
width: 30_000,
|
||||
height: 400,
|
||||
});
|
||||
assert.ok(
|
||||
wildlifeGridTiles({ boxes: globe, z }).length <= WILDLIFE_GRID_TILE_CAP,
|
||||
);
|
||||
assert.deepEqual(wildlifeGridTiles({ boxes: globe, z: 12 }), []);
|
||||
assert.equal(chooseWildlifeGridZoom({ boxes: [], width: 0, height: 0 }), 0);
|
||||
assert.deepEqual(wildlifeGridTiles({ boxes: [], z: 2 }), []);
|
||||
});
|
||||
|
||||
test('grid camera viewport supports globe and antimeridian without detail-query degree limits', () => {
|
||||
const radians = (value) => (value * Math.PI) / 180;
|
||||
const viewer = {
|
||||
camera: {
|
||||
computeViewRectangle: () => ({
|
||||
south: radians(-80),
|
||||
west: radians(120),
|
||||
north: radians(80),
|
||||
east: radians(-120),
|
||||
}),
|
||||
},
|
||||
};
|
||||
const boxes = wildlifeGridViewportBoxes(viewer);
|
||||
assert.equal(boxes.length, 2);
|
||||
assert.ok(Math.abs(boxes[0].west - 120) < 1e-8);
|
||||
assert.equal(boxes[0].east, 180);
|
||||
assert.equal(boxes[1].west, -180);
|
||||
assert.deepEqual(wildlifeGridViewportBoxes({}), []);
|
||||
});
|
||||
|
||||
test('logical cell ids and bounds share geographic coverage and wrap antimeridian', () => {
|
||||
const id = wildlifeGridCellId(65.94004866768753, -18.28125, 2);
|
||||
assert.equal(id, 'grid:2/1/1:1:51');
|
||||
const bounds = wildlifeGridCellBounds(id);
|
||||
assert.equal(bounds.west, -19.6875);
|
||||
assert.equal(bounds.east, -16.875);
|
||||
assert.ok(bounds.south < 65.94 && bounds.north > 65.94);
|
||||
assert.equal(
|
||||
wildlifeGridCellId(
|
||||
(bounds.south + bounds.north) / 2,
|
||||
(bounds.west + bounds.east) / 2,
|
||||
2,
|
||||
),
|
||||
id,
|
||||
);
|
||||
assert.equal(wildlifeGridCellId(0, 180, 2), wildlifeGridCellId(0, -180, 2));
|
||||
for (const invalid of [
|
||||
'bad',
|
||||
'grid:23/0/0:1:1',
|
||||
'grid:2/4/0:1:1',
|
||||
'grid:2/0/0:64:1',
|
||||
])
|
||||
assert.equal(wildlifeGridCellBounds(invalid), null);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import { createPhotoSlots } from './photos.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createSelection } from './selection.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
|
||||
/** Compose recent wildlife observations without acquiring data at construction. */
|
||||
export function createWildlifeSightingsLayer({
|
||||
source,
|
||||
overlayHost = null,
|
||||
screenSpaceEventHandlerFactory = null,
|
||||
picking = null,
|
||||
pointer = null,
|
||||
sprites = null,
|
||||
badgeSprites = null,
|
||||
render = null,
|
||||
context = null,
|
||||
focus = null,
|
||||
groundFloor = null,
|
||||
now = () => Date.now(),
|
||||
setTimer = (fn, delay) => setTimeout(fn, delay),
|
||||
clearTimer = (timer) => clearTimeout(timer),
|
||||
requestIdleCallback,
|
||||
cancelIdleCallback,
|
||||
imageFactory,
|
||||
eventTarget = globalThis.window,
|
||||
cesium = Cesium,
|
||||
project = (scene, position) =>
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates(scene, position),
|
||||
occluderFactory = horizonOccluder,
|
||||
} = {}) {
|
||||
if (typeof source?.getSnapshot !== 'function')
|
||||
throw new TypeError('Wildlife sightings require a snapshot source');
|
||||
const state = {
|
||||
viewer: null,
|
||||
enabled: false,
|
||||
destroyed: false,
|
||||
manager: null,
|
||||
request: null,
|
||||
background: null,
|
||||
cameraTimer: null,
|
||||
removeCamera: null,
|
||||
removeContext: null,
|
||||
clickHandler: null,
|
||||
rows: new Map(),
|
||||
renderedRows: [],
|
||||
rowById: new Map(),
|
||||
points: null,
|
||||
pointById: new Map(),
|
||||
positions: new Map(),
|
||||
cells: [],
|
||||
staging: null,
|
||||
selectedId: null,
|
||||
selectedRow: null,
|
||||
carriers: new Map(),
|
||||
contextIds: new Set(),
|
||||
publishingContext: false,
|
||||
ambientIds: [],
|
||||
ambientRows: [],
|
||||
confirmed: false,
|
||||
taxa: 'all',
|
||||
drawnParams: null,
|
||||
pendingParams: null,
|
||||
failedParams: null,
|
||||
controlsListener: null,
|
||||
lastUpdate: null,
|
||||
error: null,
|
||||
stale: false,
|
||||
loading: false,
|
||||
gridCells: [],
|
||||
gridParams: null,
|
||||
gridAvailable: false,
|
||||
gridTotal: 0,
|
||||
labelRows: [],
|
||||
query: null,
|
||||
band: null,
|
||||
lastQueryKey: null,
|
||||
retryAt: 0,
|
||||
failures: 0,
|
||||
};
|
||||
const parts = {};
|
||||
const services = {
|
||||
source,
|
||||
overlayHost,
|
||||
screenSpaceEventHandlerFactory,
|
||||
picking,
|
||||
pointer,
|
||||
sprites,
|
||||
badgeSprites,
|
||||
render,
|
||||
context,
|
||||
focus,
|
||||
groundFloor,
|
||||
now,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
requestIdleCallback,
|
||||
cancelIdleCallback,
|
||||
eventTarget,
|
||||
cesium,
|
||||
project,
|
||||
occluderFactory,
|
||||
};
|
||||
const owner = { state, services, parts };
|
||||
parts.photos = createPhotoSlots({
|
||||
imageFactory,
|
||||
now,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
onChange() {
|
||||
if (!state.enabled) return;
|
||||
parts.selection.publishAmbient();
|
||||
},
|
||||
});
|
||||
parts.rendering = createRendering(owner);
|
||||
parts.selection = createSelection(owner);
|
||||
parts.controls = createControls(owner);
|
||||
parts.lifecycle = createLifecycle(owner);
|
||||
return Object.assign({}, parts.controls.methods, parts.lifecycle.methods, {
|
||||
selectSighting: parts.selection.selectAndFocus,
|
||||
clearSightingSelection: parts.selection.clearAndRebuild,
|
||||
subscribeSelection: parts.selection.subscribe,
|
||||
/** Return the current surface anchor for a retained observation. */
|
||||
getSightingPosition(id) {
|
||||
return state.positions.get(id) || null;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,851 @@
|
||||
import { msg } from '../../i18n/marker.js';
|
||||
import { getWildlifeHeightBand, rowInWildlifeBoxes } from './model.js';
|
||||
import {
|
||||
combineWildlifeGridSnapshots,
|
||||
preferredWildlifeFailure,
|
||||
wildlifeFailureDelayMs,
|
||||
wildlifeFailures,
|
||||
wildlifeRetryAfterMs,
|
||||
} from './source.js';
|
||||
import { wildlifeMeasure } from './performance.js';
|
||||
import { isRecentWildlifeSighting } from './records.js';
|
||||
import {
|
||||
wildlifeGridViewportBoxes,
|
||||
chooseWildlifeGridZoom,
|
||||
wildlifeGridTiles,
|
||||
wildlifeGridCellId,
|
||||
} from './gridViewport.js';
|
||||
import { wildlifeViewportBoxes, snapWildlifeBox } from './viewport.js';
|
||||
import {
|
||||
WILDLIFE_DEBOUNCE_MS,
|
||||
WILDLIFE_MAX_ROWS,
|
||||
WILDLIFE_GRID_HEIGHT_METERS,
|
||||
} from './policy.js';
|
||||
|
||||
/** Manager-owned acquisition with camera debounce and guarded transactions. */
|
||||
export function createLifecycle({ state, services, parts }) {
|
||||
let requestQueryKey = null;
|
||||
let removeManagerListener = null;
|
||||
let lifetimeEpoch = 0;
|
||||
let initialCameraDeadline = 0;
|
||||
let initialSettle = null;
|
||||
// The manager invokes update immediately after enable. Settle startup camera
|
||||
// events before dispatching anything, then acquire only the resulting view.
|
||||
const INITIAL_CAMERA_SETTLE_MS = 300;
|
||||
|
||||
function settleInitialCamera(signal) {
|
||||
if (!initialCameraDeadline || services.now() >= initialCameraDeadline) {
|
||||
initialCameraDeadline = 0;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const finish = () => {
|
||||
services.clearTimer(state.cameraTimer);
|
||||
state.cameraTimer = null;
|
||||
initialSettle = null;
|
||||
initialCameraDeadline = 0;
|
||||
signal?.removeEventListener('abort', finish);
|
||||
resolve();
|
||||
};
|
||||
initialSettle = {
|
||||
schedule() {
|
||||
services.clearTimer(state.cameraTimer);
|
||||
state.cameraTimer = services.setTimer(
|
||||
finish,
|
||||
Math.max(0, initialCameraDeadline - services.now()),
|
||||
);
|
||||
},
|
||||
finish,
|
||||
};
|
||||
signal?.addEventListener('abort', finish, { once: true });
|
||||
initialSettle.schedule();
|
||||
});
|
||||
}
|
||||
|
||||
function renderBatch(fn) {
|
||||
parts.rendering.beginBatch?.();
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
parts.rendering.endBatch?.();
|
||||
}
|
||||
}
|
||||
|
||||
function clearPending() {
|
||||
if (!state.pendingParams) return;
|
||||
if (state.error) state.failedParams = state.pendingParams;
|
||||
state.pendingParams = null;
|
||||
parts.controls.notify();
|
||||
}
|
||||
|
||||
function failParams(params) {
|
||||
// An acquisition can fail during an ordinary refresh, when no option is
|
||||
// pending. Keep the chosen taxa visible and retryable in that case too.
|
||||
state.failedParams = {
|
||||
...params,
|
||||
keys: [
|
||||
...new Set([
|
||||
...(state.pendingParams?.keys || []),
|
||||
'taxa',
|
||||
...(params.confirmed ? ['confirmed'] : []),
|
||||
]),
|
||||
],
|
||||
};
|
||||
state.pendingParams = 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 backoff = Math.max(...delays);
|
||||
state.failures = backoff;
|
||||
state.retryAt = services.now() + backoff;
|
||||
}
|
||||
|
||||
function pageHidden() {
|
||||
return (
|
||||
services.eventTarget?.document?.hidden || globalThis.document?.hidden
|
||||
);
|
||||
}
|
||||
|
||||
function currentQuery() {
|
||||
const height = state.viewer?.camera?.positionCartographic?.height;
|
||||
state.band = getWildlifeHeightBand(height ?? Infinity, state.band);
|
||||
const boxes =
|
||||
state.band === 'global'
|
||||
? null
|
||||
: wildlifeViewportBoxes(state.viewer)?.map((box) =>
|
||||
snapWildlifeBox(box),
|
||||
);
|
||||
const visibleBoxes = wildlifeGridViewportBoxes(state.viewer);
|
||||
const scope =
|
||||
state.band === 'global' ||
|
||||
(!boxes && (height ?? Infinity) > WILDLIFE_GRID_HEIGHT_METERS)
|
||||
? 'global'
|
||||
: 'view';
|
||||
if (scope === 'view' && (!boxes?.length || boxes.some((box) => !box)))
|
||||
return null;
|
||||
const baseKey = JSON.stringify([
|
||||
scope,
|
||||
scope === 'global' ? null : boxes,
|
||||
state.confirmed,
|
||||
state.taxa,
|
||||
]);
|
||||
const canvas = state.viewer?.scene?.canvas;
|
||||
const zoom = visibleBoxes?.length
|
||||
? chooseWildlifeGridZoom({
|
||||
boxes: visibleBoxes,
|
||||
width: canvas?.clientWidth || 1400,
|
||||
height: canvas?.clientHeight || 900,
|
||||
})
|
||||
: 0;
|
||||
const tiles = visibleBoxes?.length
|
||||
? wildlifeGridTiles({ boxes: visibleBoxes, z: zoom })
|
||||
: [];
|
||||
return {
|
||||
scope,
|
||||
boxes: scope === 'global' ? null : boxes,
|
||||
visibleBoxes,
|
||||
tiles,
|
||||
key: JSON.stringify([baseKey, tiles]),
|
||||
};
|
||||
}
|
||||
|
||||
function refreshView() {
|
||||
const query = currentQuery();
|
||||
const superseded = state.query?.key !== query?.key;
|
||||
state.query = query;
|
||||
if (superseded) {
|
||||
state.request?.abort();
|
||||
state.background?.abort();
|
||||
}
|
||||
// Keep the previous validated display while changing filters. Its rows
|
||||
// and parameter chips are replaced together when acquisition succeeds.
|
||||
renderBatch(() => {
|
||||
const pending =
|
||||
!state.pendingClear && parts.rendering.rebuild() === 'pending';
|
||||
parts.rendering.cull();
|
||||
if (pending) return;
|
||||
parts.selection.refreshAmbient();
|
||||
parts.selection.refreshContexts();
|
||||
parts.selection.syncCredits();
|
||||
});
|
||||
return query;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!state.enabled || pageHidden()) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const pendingParams = state.pendingParams;
|
||||
state.background?.abort();
|
||||
state.background = controller;
|
||||
try {
|
||||
if (state.manager)
|
||||
await state.manager.refreshLayer('wildlife-sightings', {
|
||||
signal: controller.signal,
|
||||
});
|
||||
else await methods.update(undefined, { signal: controller.signal });
|
||||
} catch {
|
||||
// update exposes expected source errors through stats; manager failures
|
||||
// already have their own normalized status path.
|
||||
} finally {
|
||||
if (state.background === controller) {
|
||||
state.background = null;
|
||||
// A manager may decline acquisition while it is enabling, or the view
|
||||
// may disappear before update. Only queued/in-flight work is busy.
|
||||
if (
|
||||
state.pendingParams === pendingParams &&
|
||||
!state.loading &&
|
||||
state.cameraTimer == null
|
||||
)
|
||||
clearPending();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
services.clearTimer(state.cameraTimer);
|
||||
state.cameraTimer = null;
|
||||
if (!state.enabled || pageHidden()) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
if (initialSettle) {
|
||||
initialSettle.schedule();
|
||||
return;
|
||||
}
|
||||
const query = currentQuery();
|
||||
if (!query || query.key === state.lastQueryKey) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
query.key === requestQueryKey &&
|
||||
state.request &&
|
||||
!state.request.signal.aborted
|
||||
)
|
||||
return;
|
||||
state.cameraTimer = services.setTimer(
|
||||
() => {
|
||||
state.cameraTimer = null;
|
||||
if (!state.enabled || pageHidden()) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
const query = currentQuery();
|
||||
if (!query || query.key === state.lastQueryKey) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
query.key === requestQueryKey &&
|
||||
state.request &&
|
||||
!state.request.signal.aborted
|
||||
)
|
||||
return;
|
||||
if (services.now() < state.retryAt) {
|
||||
scheduleRefresh();
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
},
|
||||
Math.max(WILDLIFE_DEBOUNCE_MS, state.retryAt - services.now()),
|
||||
);
|
||||
}
|
||||
|
||||
function applyAccepted({
|
||||
snapshot,
|
||||
grid,
|
||||
gridError,
|
||||
query,
|
||||
params,
|
||||
request,
|
||||
signal,
|
||||
epoch,
|
||||
previousFailures,
|
||||
replaceRows = true,
|
||||
}) {
|
||||
const current = currentQuery();
|
||||
if (
|
||||
!state.enabled ||
|
||||
state.destroyed ||
|
||||
epoch !== lifetimeEpoch ||
|
||||
request.signal.aborted ||
|
||||
signal?.aborted ||
|
||||
params.taxa !== state.taxa ||
|
||||
params.confirmed !== state.confirmed ||
|
||||
current?.key !== query.key
|
||||
)
|
||||
return;
|
||||
renderBatch(() => {
|
||||
const sameParams =
|
||||
params.taxa === state.drawnParams?.taxa &&
|
||||
params.confirmed === state.drawnParams?.confirmed;
|
||||
const gridParams = state.gridParams || state.drawnParams;
|
||||
const sameGridParams =
|
||||
params.taxa === gridParams?.taxa &&
|
||||
params.confirmed === gridParams?.confirmed;
|
||||
// Tiles may beat a slow observation page. Publish their density without
|
||||
// replacing observations or claiming their filter transaction succeeded.
|
||||
// A pending filter keeps its previous display until both paths agree.
|
||||
if (!snapshot) {
|
||||
if (state.drawnParams && !sameParams) return;
|
||||
if (grid) {
|
||||
state.gridCells = grid.cells || [];
|
||||
state.gridParams = params;
|
||||
state.gridAvailable = true;
|
||||
state.gridTotal = grid.totalResults || 0;
|
||||
if (state.lastUpdate == null)
|
||||
state.lastUpdate = Number.isFinite(grid.fetchedAt)
|
||||
? grid.fetchedAt
|
||||
: services.now();
|
||||
const pendingRender = parts.rendering.rebuild() === 'pending';
|
||||
if (!pendingRender) {
|
||||
parts.selection.refreshAmbient();
|
||||
parts.selection.publishSelected();
|
||||
parts.selection.refreshContexts();
|
||||
parts.selection.syncCredits();
|
||||
}
|
||||
parts.controls.notify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (replaceRows) {
|
||||
if (state.selectedId && !state.selectedRow)
|
||||
state.selectedRow =
|
||||
state.rowById.get(state.selectedId) ||
|
||||
state.rows.get(state.selectedId) ||
|
||||
null;
|
||||
const retainedRows = sameParams
|
||||
? (parts.selection.retainedAmbientRows?.() || []).filter(
|
||||
(row) =>
|
||||
state.rows.has(row.stableId) &&
|
||||
isRecentWildlifeSighting(row, services.now(), params.taxa),
|
||||
)
|
||||
: [];
|
||||
const retainedIds = new Set(retainedRows.map((row) => row.stableId));
|
||||
if (state.selectedRow) retainedIds.add(state.selectedRow.stableId);
|
||||
if (state.pendingClear || state.loadedScope !== query.scope)
|
||||
state.rows.clear();
|
||||
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. 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) {
|
||||
state.rows.delete(row.stableId);
|
||||
state.rows.set(row.stableId, row);
|
||||
if (row.stableId === state.selectedId) state.selectedRow = row;
|
||||
}
|
||||
// Selection owns a real observation until explicit deselection. It is
|
||||
// independent of sample/window/filter and ambient-card eligibility.
|
||||
if (state.selectedRow && !state.rows.has(state.selectedId))
|
||||
state.rows.set(state.selectedId, state.selectedRow);
|
||||
// Random samples may omit a photograph already displayed. Its bounded
|
||||
// incumbent row stays selectable until the card leaves the view/window.
|
||||
for (const row of retainedRows) {
|
||||
if (!state.rows.has(row.stableId)) state.rows.set(row.stableId, row);
|
||||
}
|
||||
if (state.rows.size > WILDLIFE_MAX_ROWS) {
|
||||
for (const id of state.rows.keys()) {
|
||||
if (!retainedIds.has(id)) state.rows.delete(id);
|
||||
if (state.rows.size <= WILDLIFE_MAX_ROWS) break;
|
||||
}
|
||||
}
|
||||
if (state.rows.size > WILDLIFE_MAX_ROWS) {
|
||||
for (const id of state.rows.keys()) {
|
||||
if (id !== state.selectedId) state.rows.delete(id);
|
||||
if (state.rows.size <= WILDLIFE_MAX_ROWS) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const failedStale = [snapshot, grid].filter(
|
||||
(value) =>
|
||||
value?.stale === true &&
|
||||
Number.isFinite(value.retryAfterSec) &&
|
||||
(value.upstreamStatus === undefined ||
|
||||
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),
|
||||
),
|
||||
},
|
||||
)
|
||||
: null;
|
||||
const inat = snapshot.sources?.inaturalist;
|
||||
const acartia = snapshot.sources?.acartia;
|
||||
// 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,
|
||||
inat?.ok === false
|
||||
? inat.error ||
|
||||
new Error(msg('Wildlife sightings source unavailable'))
|
||||
: 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 = preferredWildlifeFailure(
|
||||
sourceErrors,
|
||||
previousFailures,
|
||||
);
|
||||
state.stale =
|
||||
!sourceError && (snapshot.stale === true || grid?.stale === true);
|
||||
const fetchedAt = [snapshot.fetchedAt, grid?.fetchedAt].filter(
|
||||
Number.isFinite,
|
||||
);
|
||||
state.lastUpdate =
|
||||
(snapshot.stale === true || grid?.stale === true) && fetchedAt.length
|
||||
? Math.min(...fetchedAt)
|
||||
: services.now();
|
||||
state.error = sourceError?.message || null;
|
||||
state.drawnParams = params;
|
||||
const retryAfter = [snapshot, grid]
|
||||
.filter(
|
||||
(value) =>
|
||||
value &&
|
||||
(value.upstreamStatus === undefined ||
|
||||
value.upstreamStatus === 429 ||
|
||||
value.upstreamStatus >= 500),
|
||||
)
|
||||
.map((value) => value.retryAfterSec)
|
||||
.filter(Number.isFinite);
|
||||
if (sourceError) {
|
||||
failParams(params);
|
||||
applyRetry(
|
||||
sourceErrors,
|
||||
retryAfter.length ? Math.max(...retryAfter) : null,
|
||||
previousFailures,
|
||||
);
|
||||
} else {
|
||||
state.pendingParams = null;
|
||||
state.failedParams = null;
|
||||
state.retryAt = retryAfter.length
|
||||
? services.now() + wildlifeRetryAfterMs(Math.max(...retryAfter))
|
||||
: 0;
|
||||
state.failures = 0;
|
||||
}
|
||||
state.query = currentQuery();
|
||||
if (grid !== undefined || !sameGridParams) {
|
||||
state.gridCells = grid?.cells || [];
|
||||
state.gridParams = grid ? params : null;
|
||||
state.gridAvailable = !!grid;
|
||||
state.gridTotal = grid?.totalResults || 0;
|
||||
}
|
||||
const pendingRender = parts.rendering.rebuild() === 'pending';
|
||||
if (pendingRender) {
|
||||
state.lastQueryKey = sourceError ? null : state.query?.key || query.key;
|
||||
parts.controls.notify();
|
||||
return;
|
||||
}
|
||||
parts.selection.refreshAmbient();
|
||||
parts.selection.publishSelected();
|
||||
parts.selection.notify();
|
||||
parts.selection.refreshContexts();
|
||||
parts.selection.syncCredits();
|
||||
state.lastQueryKey = sourceError ? null : state.query?.key || query.key;
|
||||
parts.controls.notify();
|
||||
});
|
||||
}
|
||||
|
||||
function disable() {
|
||||
lifetimeEpoch += 1;
|
||||
initialSettle?.finish();
|
||||
initialCameraDeadline = 0;
|
||||
state.enabled = false;
|
||||
services.clearTimer(state.cameraTimer);
|
||||
state.cameraTimer = null;
|
||||
state.request?.abort();
|
||||
state.request = null;
|
||||
requestQueryKey = null;
|
||||
state.background?.abort();
|
||||
state.background = null;
|
||||
state.loading = false;
|
||||
clearPending();
|
||||
state.removeCamera?.();
|
||||
state.removeCamera = null;
|
||||
parts.rendering.deactivate?.();
|
||||
removeManagerListener?.();
|
||||
removeManagerListener = null;
|
||||
parts.selection.remove();
|
||||
services.picking?.unregisterPickOwner?.('wildlife-sightings');
|
||||
parts.rendering.clear();
|
||||
parts.photos.clear();
|
||||
parts.controls.notify();
|
||||
parts.rendering.requestRender('disable');
|
||||
return true;
|
||||
}
|
||||
|
||||
const methods = {
|
||||
/** Attach this layer's Cesium resources to one viewer. */
|
||||
init(viewer, { signal } = {}) {
|
||||
if (state.viewer)
|
||||
throw new Error('Wildlife sightings layer is already initialized');
|
||||
if (state.destroyed || signal?.aborted) return false;
|
||||
state.viewer = viewer;
|
||||
parts.rendering.init();
|
||||
return true;
|
||||
},
|
||||
/** Show retained observations and acquire the settled camera view. */
|
||||
enable(_viewer, { signal } = {}) {
|
||||
if (state.destroyed || signal?.aborted) return false;
|
||||
if (!state.viewer)
|
||||
throw new Error('Wildlife sightings layer must be initialized');
|
||||
if (state.enabled) return true;
|
||||
state.enabled = true;
|
||||
initialCameraDeadline = services.now() + INITIAL_CAMERA_SETTLE_MS;
|
||||
parts.rendering.activate?.();
|
||||
state.points.show = true;
|
||||
refreshView();
|
||||
for (const id of parts.selection.sourceIds)
|
||||
services.overlayHost?.setVisible?.(id, true);
|
||||
parts.selection.install();
|
||||
services.picking?.registerPickOwner?.(
|
||||
'wildlife-sightings',
|
||||
(id) =>
|
||||
typeof id === 'string' &&
|
||||
id.startsWith('wildlife:') &&
|
||||
state.rowById.has(id.slice(9)),
|
||||
);
|
||||
state.removeCamera = state.viewer.camera?.moveEnd?.addEventListener?.(
|
||||
() => {
|
||||
if (!state.enabled) return;
|
||||
wildlifeMeasure('moveEnd', () => {
|
||||
if (
|
||||
initialCameraDeadline &&
|
||||
services.now() < initialCameraDeadline
|
||||
) {
|
||||
initialCameraDeadline = services.now() + INITIAL_CAMERA_SETTLE_MS;
|
||||
refreshView();
|
||||
initialSettle?.schedule();
|
||||
} else {
|
||||
refreshView();
|
||||
scheduleRefresh();
|
||||
}
|
||||
parts.controls.notify();
|
||||
});
|
||||
},
|
||||
);
|
||||
// A debounce can expire while the manager is still enabling and cannot
|
||||
// accept refreshLayer yet. Its settled visibility event is the one
|
||||
// catch-up edge; completed initial queries are skipped by their key.
|
||||
removeManagerListener =
|
||||
state.manager?.subscribe?.((change) => {
|
||||
if (
|
||||
change.type === 'visibility' &&
|
||||
change.layerId === 'wildlife-sightings' &&
|
||||
change.enabled === true &&
|
||||
state.enabled
|
||||
)
|
||||
scheduleRefresh();
|
||||
}) || null;
|
||||
if (services.sprites?.restoreSpriteOrderOnEnable)
|
||||
services.sprites.restoreSpriteOrderOnEnable(
|
||||
'wildlife-sightings',
|
||||
state.viewer,
|
||||
);
|
||||
else services.sprites?.restoreSpriteOrder?.(state.viewer);
|
||||
return true;
|
||||
},
|
||||
/** Refresh observations and density independently for the current filters. */
|
||||
async update(_viewer, { signal } = {}) {
|
||||
if (
|
||||
!state.enabled ||
|
||||
!state.points ||
|
||||
state.destroyed ||
|
||||
signal?.aborted
|
||||
) {
|
||||
if (!state.request) clearPending();
|
||||
return false;
|
||||
}
|
||||
if (initialCameraDeadline && services.now() < initialCameraDeadline)
|
||||
await settleInitialCamera(signal);
|
||||
initialCameraDeadline = 0;
|
||||
if (!state.enabled || state.destroyed || signal?.aborted) return false;
|
||||
const query = currentQuery();
|
||||
state.query = query;
|
||||
if (!query || pageHidden()) {
|
||||
clearPending();
|
||||
return true;
|
||||
}
|
||||
if (services.now() < state.retryAt) {
|
||||
if (state.pendingParams && state.cameraTimer == null) scheduleRefresh();
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
requestQueryKey === query.key &&
|
||||
state.request &&
|
||||
!state.request.signal.aborted
|
||||
)
|
||||
return true;
|
||||
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 &&
|
||||
lifetimeEpoch === requestLifetime &&
|
||||
!signal?.aborted;
|
||||
state.request = request;
|
||||
requestQueryKey = query.key;
|
||||
const onAbort = () => request.abort(signal.reason);
|
||||
signal?.addEventListener?.('abort', onAbort, { once: true });
|
||||
const params = { confirmed: state.confirmed, taxa: state.taxa };
|
||||
state.loading = true;
|
||||
if (!state.pendingParams) {
|
||||
const drawn = state.drawnParams || { confirmed: false, taxa: null };
|
||||
const keys = ['confirmed', 'taxa'].filter(
|
||||
(key) => params[key] !== drawn[key],
|
||||
);
|
||||
if (keys.length) state.pendingParams = { ...params, keys };
|
||||
}
|
||||
parts.controls.notify();
|
||||
let received = false;
|
||||
let gridPending = false;
|
||||
let acceptedSnapshot = null;
|
||||
let gridRequest = null;
|
||||
let gridResult = null;
|
||||
const tileSnapshots = new Map();
|
||||
const gridParams = state.gridParams || state.drawnParams;
|
||||
const retainedGrid =
|
||||
params.taxa === gridParams?.taxa &&
|
||||
params.confirmed === gridParams?.confirmed
|
||||
? state.gridCells || []
|
||||
: [];
|
||||
function aggregateTiles(complete = false) {
|
||||
if (!tileSnapshots.size) return undefined;
|
||||
const grid = combineWildlifeGridSnapshots([...tileSnapshots.values()]);
|
||||
if (!complete && retainedGrid.length) {
|
||||
const zoom = query.tiles[0]?.z;
|
||||
const covered = new Set(tileSnapshots.keys());
|
||||
const retained = retainedGrid.filter(
|
||||
(cell) =>
|
||||
!covered.has(
|
||||
wildlifeGridCellId(cell.lat, cell.lon, zoom)?.split(':')[1],
|
||||
),
|
||||
);
|
||||
return combineWildlifeGridSnapshots([
|
||||
{ ...grid, cells: [...retained, ...grid.cells] },
|
||||
]);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
function completedGrid(value) {
|
||||
if (!value) return value;
|
||||
const aggregate = aggregateTiles(!value.error);
|
||||
return aggregate
|
||||
? combineWildlifeGridSnapshots([aggregate, value], value.error)
|
||||
: value;
|
||||
}
|
||||
function publish(grid, gridError = null, replaceRows = false) {
|
||||
applyAccepted({
|
||||
snapshot: acceptedSnapshot,
|
||||
grid,
|
||||
gridError,
|
||||
replaceRows,
|
||||
query,
|
||||
params,
|
||||
request,
|
||||
signal,
|
||||
epoch: requestLifetime,
|
||||
previousFailures,
|
||||
});
|
||||
}
|
||||
function finishTiles() {
|
||||
signal?.removeEventListener?.('abort', onAbort);
|
||||
if (state.request === request) {
|
||||
state.request = null;
|
||||
requestQueryKey = null;
|
||||
state.loading = false;
|
||||
if (request.signal.aborted && state.cameraTimer == null)
|
||||
clearPending();
|
||||
parts.controls.notify();
|
||||
}
|
||||
}
|
||||
function continueTiles() {
|
||||
if (!gridRequest || gridResult) return;
|
||||
gridPending = true;
|
||||
void gridRequest
|
||||
.then((result) => {
|
||||
if (
|
||||
acceptedSnapshot &&
|
||||
!request.signal.aborted &&
|
||||
state.request === request &&
|
||||
state.enabled
|
||||
)
|
||||
publish(result?.value, result?.error || result?.value?.error);
|
||||
})
|
||||
.finally(finishTiles);
|
||||
}
|
||||
try {
|
||||
const options = {
|
||||
signal: request.signal,
|
||||
boxes: query.boxes,
|
||||
box: query.boxes?.[0] || null,
|
||||
scope: query.scope,
|
||||
...params,
|
||||
};
|
||||
// Observation requests enter the shared proxy queue first. A slow tile
|
||||
// never holds the manager's initial enable or the first observations.
|
||||
const observationRequest =
|
||||
typeof services.source.getSnapshotDetail === 'function'
|
||||
? services.source.getSnapshotDetail(options)
|
||||
: Promise.resolve(services.source.getSnapshot(options)).then(
|
||||
(rows) => ({ rows, stale: false, fetchedAt: services.now() }),
|
||||
);
|
||||
const hasGrid =
|
||||
typeof services.source.getGridSnapshot === 'function' &&
|
||||
query.tiles.length;
|
||||
gridRequest = hasGrid
|
||||
? services.source
|
||||
.getGridSnapshot({
|
||||
signal: request.signal,
|
||||
tiles: query.tiles,
|
||||
...params,
|
||||
onTile(value, { tile }) {
|
||||
if (request.signal.aborted || state.request !== request)
|
||||
return;
|
||||
tileSnapshots.set(`${tile.z}/${tile.x}/${tile.y}`, value);
|
||||
publish(aggregateTiles());
|
||||
},
|
||||
})
|
||||
.then(
|
||||
(value) => {
|
||||
gridResult = { value: completedGrid(value) };
|
||||
if (!acceptedSnapshot)
|
||||
publish(gridResult.value, value?.error);
|
||||
return gridResult;
|
||||
},
|
||||
(error) => (gridResult = { error }),
|
||||
)
|
||||
: null;
|
||||
const snapshot = await observationRequest;
|
||||
if (
|
||||
request.signal.aborted ||
|
||||
state.request !== request ||
|
||||
!state.enabled
|
||||
)
|
||||
return supersededResult();
|
||||
if (!Array.isArray(snapshot?.rows))
|
||||
throw new Error('Malformed iNaturalist response');
|
||||
received = true;
|
||||
acceptedSnapshot = snapshot;
|
||||
publish(
|
||||
gridResult?.value || aggregateTiles(),
|
||||
gridResult?.error || gridResult?.value?.error,
|
||||
true,
|
||||
);
|
||||
if (hasGrid && !gridResult) {
|
||||
continueTiles();
|
||||
} else if (gridResult && !gridResult.value && gridResult.error) {
|
||||
// Observation rows remain visible when the first tile set fails.
|
||||
publish(undefined, gridResult.error);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (
|
||||
request.signal.aborted ||
|
||||
state.request !== request ||
|
||||
!state.enabled
|
||||
)
|
||||
return supersededResult();
|
||||
if (received) throw error;
|
||||
// Observation failures must not cancel independent density tiles.
|
||||
continueTiles();
|
||||
state.stale = false;
|
||||
state.error =
|
||||
preferredWildlifeFailure(error, previousFailures)?.message ||
|
||||
msg('Wildlife sightings source unavailable');
|
||||
failParams(params);
|
||||
state.lastQueryKey = null;
|
||||
requestQueryKey = null;
|
||||
applyRetry(error, null, previousFailures);
|
||||
console.warn('[Data:WildlifeSightings] Update failed:', state.error);
|
||||
return true;
|
||||
} finally {
|
||||
if (!gridPending) finishTiles();
|
||||
}
|
||||
},
|
||||
/** Stop acquisition and release visible resources while retaining filters. */
|
||||
disable,
|
||||
/** Release requests, subscriptions, caches and scene resources. */
|
||||
destroy() {
|
||||
if (state.destroyed) return;
|
||||
disable();
|
||||
state.destroyed = true;
|
||||
parts.selection.destroy();
|
||||
parts.photos.destroy();
|
||||
parts.rendering.destroy();
|
||||
state.rows.clear();
|
||||
state.lastUpdate = null;
|
||||
state.error = null;
|
||||
state.stale = false;
|
||||
state.drawnParams = null;
|
||||
state.gridParams = null;
|
||||
state.pendingParams = null;
|
||||
state.failedParams = null;
|
||||
state.controlsListener = null;
|
||||
state.manager = null;
|
||||
state.viewer = null;
|
||||
},
|
||||
};
|
||||
return {
|
||||
methods,
|
||||
scheduleRefresh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import {
|
||||
WILDLIFE_HEIGHT_BANDS,
|
||||
WILDLIFE_HEIGHT_HYSTERESIS,
|
||||
WILDLIFE_RENDER_LIMIT,
|
||||
WILDLIFE_VIEW_PADDING,
|
||||
} from './policy.js';
|
||||
import { WILDLIFE_GRID_TARGET_SPACING_PX } from './gridViewport.js';
|
||||
import { drainWildlifeWork, WILDLIFE_WORK_UNIT } from './performance.js';
|
||||
|
||||
const BAND_ORDER = Object.freeze(['cards', 'cells', 'regional', 'global']);
|
||||
const BAND_THRESHOLDS = Object.freeze([
|
||||
WILDLIFE_HEIGHT_BANDS.cards,
|
||||
WILDLIFE_HEIGHT_BANDS.cells,
|
||||
WILDLIFE_HEIGHT_BANDS.global,
|
||||
]);
|
||||
|
||||
/** A previous band is retained until a boundary is crossed by ten percent. */
|
||||
export function getWildlifeHeightBand(heightM, previous) {
|
||||
const height = Number.isFinite(heightM) ? Math.max(0, heightM) : 0;
|
||||
let index = BAND_ORDER.indexOf(previous);
|
||||
if (index < 0)
|
||||
return BAND_ORDER[
|
||||
BAND_THRESHOLDS.filter((boundary) => height > boundary).length
|
||||
];
|
||||
while (
|
||||
index < BAND_ORDER.length - 1 &&
|
||||
height > BAND_THRESHOLDS[index] * (1 + WILDLIFE_HEIGHT_HYSTERESIS)
|
||||
)
|
||||
index += 1;
|
||||
while (
|
||||
index > 0 &&
|
||||
height < BAND_THRESHOLDS[index - 1] * (1 - WILDLIFE_HEIGHT_HYSTERESIS)
|
||||
)
|
||||
index -= 1;
|
||||
return BAND_ORDER[index];
|
||||
}
|
||||
|
||||
/** Share the legend's animal grouping and unknown fallback with sprites. */
|
||||
export function wildlifeIconicGroup(iconic) {
|
||||
if (iconic === 'Mollusca' || iconic === 'Animalia') return 'Animalia';
|
||||
return [
|
||||
'Aves',
|
||||
'Mammalia',
|
||||
'Reptilia',
|
||||
'Amphibia',
|
||||
'Actinopterygii',
|
||||
'Insecta',
|
||||
'Arachnida',
|
||||
'Plantae',
|
||||
'Fungi',
|
||||
].includes(iconic)
|
||||
? iconic
|
||||
: 'Unknown';
|
||||
}
|
||||
|
||||
function validIconPosition(row) {
|
||||
return (
|
||||
Number.isFinite(row?.lat) &&
|
||||
Math.abs(row.lat) <= 90 &&
|
||||
Number.isFinite(row?.lon) &&
|
||||
Math.abs(row.lon) <= 180
|
||||
);
|
||||
}
|
||||
|
||||
/** Compare provider IDs consistently across locales. */
|
||||
export function compareWildlifeIds(a, b) {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Prefer a selected/identified recent contact when a population exceeds its
|
||||
* cap. Density is never converted into a larger billboard. */
|
||||
export function wildlifeIconRelevance(row, selectedId = null) {
|
||||
return (
|
||||
(row.stableId === selectedId ? 1e15 : 0) +
|
||||
(row.commonName ? 1e13 : 0) +
|
||||
(row.quality === 'research' ? 1e12 : 0) +
|
||||
(Number.isFinite(row.observedAt) ? row.observedAt / 1000 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** Real observations alone compete for 28px screen buckets. Density is an
|
||||
* independent visual and can never supply or suppress a sighting's identity.
|
||||
* A selected observation keeps its slot even outside the new camera view. */
|
||||
export function buildWildlifeIconModel(options = {}) {
|
||||
return drainWildlifeWork(wildlifeIconModelWork(options));
|
||||
}
|
||||
|
||||
/** Yield bounded units while selecting real observations for screen buckets. */
|
||||
export function* wildlifeIconModelWork({
|
||||
rows = [],
|
||||
boxes = null,
|
||||
selectedId = null,
|
||||
selectedRow = null,
|
||||
preferredIds = [],
|
||||
limit = WILDLIFE_RENDER_LIMIT,
|
||||
projectRow = null,
|
||||
onOperations = null,
|
||||
} = {}) {
|
||||
const operations = {
|
||||
observationVisits: 0,
|
||||
projections: 0,
|
||||
rankComparisons: 0,
|
||||
};
|
||||
const preferred = new Set(preferredIds);
|
||||
const seen = new Set();
|
||||
const buckets = new Map();
|
||||
let sightingCount = 0;
|
||||
let acartiaCount = 0;
|
||||
const score = (row) =>
|
||||
wildlifeIconRelevance(row, selectedId) +
|
||||
(preferred.has(row.stableId) ? 1e14 : 0);
|
||||
const candidates =
|
||||
selectedRow && !rows.includes(selectedRow) ? [...rows, selectedRow] : rows;
|
||||
yield 'bucket';
|
||||
for (const row of candidates) {
|
||||
if (
|
||||
operations.observationVisits &&
|
||||
operations.observationVisits % WILDLIFE_WORK_UNIT === 0
|
||||
)
|
||||
yield 'bucket';
|
||||
operations.observationVisits++;
|
||||
if (
|
||||
typeof row?.stableId !== 'string' ||
|
||||
!row.stableId.length ||
|
||||
!validIconPosition(row) ||
|
||||
seen.has(row.stableId)
|
||||
)
|
||||
continue;
|
||||
seen.add(row.stableId);
|
||||
const selected = row.stableId === selectedId;
|
||||
const inView = rowInWildlifeBoxes(row, boxes, 0);
|
||||
if (!selected && !inView) continue;
|
||||
let key = row.stableId;
|
||||
let visible = inView;
|
||||
if (typeof projectRow === 'function') {
|
||||
operations.projections++;
|
||||
const screen = projectRow(row);
|
||||
visible &&=
|
||||
!!screen && Number.isFinite(screen.x) && Number.isFinite(screen.y);
|
||||
if (!visible && !selected) continue;
|
||||
if (visible) {
|
||||
const x = Math.floor(screen.x / WILDLIFE_GRID_TARGET_SPACING_PX);
|
||||
const y = Math.floor(screen.y / WILDLIFE_GRID_TARGET_SPACING_PX);
|
||||
key =
|
||||
Math.abs(x) < 1048576 && Math.abs(y) < 1048576
|
||||
? x * 2097152 + y
|
||||
: `${x}:${y}`;
|
||||
}
|
||||
}
|
||||
if (visible) {
|
||||
sightingCount++;
|
||||
if (row.origin === 'acartia') acartiaCount++;
|
||||
}
|
||||
const incumbent = buckets.get(key);
|
||||
if (
|
||||
!incumbent ||
|
||||
score(row) > score(incumbent) ||
|
||||
(score(row) === score(incumbent) && row.stableId < incumbent.stableId)
|
||||
)
|
||||
buckets.set(key, row);
|
||||
}
|
||||
yield 'rank';
|
||||
const population = [...buckets.values()];
|
||||
const cap = Math.max(selectedId ? 1 : 0, Math.floor(limit));
|
||||
const saturated = population.length > cap;
|
||||
if (saturated)
|
||||
population.sort((a, b) => {
|
||||
operations.rankComparisons++;
|
||||
return score(b) - score(a) || compareWildlifeIds(a.stableId, b.stableId);
|
||||
});
|
||||
onOperations?.(operations);
|
||||
return {
|
||||
rows: saturated ? population.slice(0, cap) : population,
|
||||
saturated,
|
||||
totalPoints: population.length,
|
||||
sightingCount,
|
||||
acartiaCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Test the padded view in degrees, including across the antimeridian. Null
|
||||
* boxes are the global sample, whose rows all remain candidates. */
|
||||
export function rowInWildlifeBoxes(
|
||||
row,
|
||||
boxes,
|
||||
padding = WILDLIFE_VIEW_PADDING,
|
||||
) {
|
||||
if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) return false;
|
||||
if (!Array.isArray(boxes)) return true;
|
||||
for (const box of boxes) {
|
||||
const latPad = (box.north - box.south) * padding;
|
||||
const width =
|
||||
box.east >= box.west ? box.east - box.west : 360 - box.west + box.east;
|
||||
const lonPad = width * padding;
|
||||
if (
|
||||
row.lat < Math.max(-90, box.south - latPad) ||
|
||||
row.lat > Math.min(90, box.north + latPad)
|
||||
)
|
||||
continue;
|
||||
const relative = row.lon - box.west;
|
||||
if (relative >= -lonPad && relative <= width + lonPad) return true;
|
||||
if (relative + 360 >= -lonPad && relative + 360 <= width + lonPad)
|
||||
return true;
|
||||
if (relative - 360 >= -lonPad && relative - 360 <= width + lonPad)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import test from 'node:test';
|
||||
import { perfBudgetTestOptions } from '../../../scripts/run-perf-tests.mjs';
|
||||
import {
|
||||
harness,
|
||||
syntheticRows,
|
||||
changePositions,
|
||||
WILDLIFE_BENCHMARK_REFRESHES,
|
||||
} from '../../testSupport/wildlifeBillboardModel.mjs';
|
||||
|
||||
// Pure model and incremental reconciliation, measured after warmup.
|
||||
// The 2,000-contact allowance is a fraction of one 60Hz frame; the 5,000 cap
|
||||
// gets a proportional allowance. No renderer, network or real sightings run.
|
||||
const WILDLIFE_MODEL_UPDATE_BUDGET_MS = Object.freeze({
|
||||
500: 4,
|
||||
2000: 8,
|
||||
5000: 20,
|
||||
});
|
||||
test(
|
||||
'500 / 2,000 / 5,000 icon model updates stay within their named timing budgets',
|
||||
perfBudgetTestOptions(),
|
||||
(t) => {
|
||||
// Warm the same pure hot paths before recording CPU time.
|
||||
const warm = harness();
|
||||
let warmRows = syntheticRows(2000);
|
||||
warm.apply(warmRows);
|
||||
for (let i = 0; i < 25; i++) {
|
||||
warmRows = changePositions(warmRows, i);
|
||||
warm.apply(warmRows);
|
||||
}
|
||||
for (const n of [500, 2000, 5000]) {
|
||||
const h = harness();
|
||||
let rows = syntheticRows(n);
|
||||
const start = performance.now();
|
||||
const initial = h.apply(rows);
|
||||
const initialMs = performance.now() - start;
|
||||
assert.equal(initial.created, n);
|
||||
const durations = [];
|
||||
for (let refresh = 0; refresh < WILDLIFE_BENCHMARK_REFRESHES; refresh++) {
|
||||
rows = changePositions(rows, refresh);
|
||||
const before = performance.now();
|
||||
const update = h.apply(rows);
|
||||
durations.push(performance.now() - before);
|
||||
assert.deepEqual(update, {
|
||||
created: 0,
|
||||
removed: 0,
|
||||
updated: n / 10,
|
||||
writes: n / 10,
|
||||
});
|
||||
}
|
||||
const meanMs =
|
||||
durations.reduce((sum, value) => sum + value, 0) / durations.length;
|
||||
const maxMs = Math.max(...durations);
|
||||
assert.ok(
|
||||
maxMs <= WILDLIFE_MODEL_UPDATE_BUDGET_MS[n],
|
||||
`${n}: ${maxMs.toFixed(3)}ms update exceeds ${WILDLIFE_MODEL_UPDATE_BUDGET_MS[n]}ms`,
|
||||
);
|
||||
const before = { ...h.counters };
|
||||
assert.deepEqual(h.apply([...rows].reverse()), {
|
||||
created: 0,
|
||||
removed: 0,
|
||||
updated: 0,
|
||||
writes: 0,
|
||||
});
|
||||
assert.deepEqual(
|
||||
h.counters,
|
||||
before,
|
||||
'unchanged content performs zero billboard writes or allocations',
|
||||
);
|
||||
t.diagnostic(
|
||||
`N=${n}; initial=${initialMs.toFixed(3)}ms; incremental10% mean=${meanMs.toFixed(3)}ms, max=${maxMs.toFixed(3)}ms; budget=${WILDLIFE_MODEL_UPDATE_BUDGET_MS[n]}ms; unchanged writes=0`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,162 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
wildlifeIconicGroup,
|
||||
rowInWildlifeBoxes,
|
||||
buildWildlifeIconModel,
|
||||
} from './model.js';
|
||||
import { ICONIC_TINTS, ICONIC_LEGEND_ORDER, TAXA_PRESETS } from './policy.js';
|
||||
|
||||
const row = {
|
||||
stableId: 'inat:1',
|
||||
lat: 47.6,
|
||||
lon: -122.3,
|
||||
cell: null,
|
||||
iconic: 'Aves',
|
||||
quality: 'research',
|
||||
count: null,
|
||||
obscured: false,
|
||||
};
|
||||
|
||||
test('unknown groups are slate and molluscs fold into other animals', () => {
|
||||
assert.equal(wildlifeIconicGroup('Mollusca'), 'Animalia');
|
||||
for (const iconic of ['Unknown', 'Protozoa', 'Chromista', undefined])
|
||||
assert.equal(wildlifeIconicGroup(iconic), 'Unknown');
|
||||
assert.equal(ICONIC_TINTS.Unknown, '#9aa7b3');
|
||||
assert.equal(ICONIC_LEGEND_ORDER.at(-1), 'Unknown');
|
||||
assert.equal(ICONIC_LEGEND_ORDER.length, 11);
|
||||
assert.equal(TAXA_PRESETS.length, 6);
|
||||
assert.equal(TAXA_PRESETS.filter((item) => item.label === 'ALL').length, 1);
|
||||
assert.equal(Object.isFrozen(ICONIC_TINTS), true);
|
||||
assert.equal(Object.isFrozen(TAXA_PRESETS[0]), true);
|
||||
});
|
||||
|
||||
test('view filtering includes padding and both sides of the antimeridian', () => {
|
||||
const boxes = [{ south: 47, west: -123, north: 48, east: -122 }];
|
||||
assert.equal(rowInWildlifeBoxes(row, boxes), true);
|
||||
assert.equal(rowInWildlifeBoxes({ ...row, lat: 48.2 }, boxes), true);
|
||||
assert.equal(rowInWildlifeBoxes({ ...row, lat: 48.4 }, boxes), false);
|
||||
assert.equal(
|
||||
rowInWildlifeBoxes({ ...row, lat: 10.5, lon: -179.9 }, [
|
||||
{ south: 10, west: 179, north: 11, east: 180 },
|
||||
]),
|
||||
true,
|
||||
);
|
||||
assert.equal(rowInWildlifeBoxes(row, null), true);
|
||||
assert.equal(rowInWildlifeBoxes(row, []), false);
|
||||
});
|
||||
|
||||
test('grid cells never enter the observation icon model', () => {
|
||||
const cells = [
|
||||
{ id: 'grid:2/0/0:1:1', lat: 47.6, lon: -122.3, cellCount: 999 },
|
||||
];
|
||||
assert.deepEqual(buildWildlifeIconModel({ rows: cells }).rows, []);
|
||||
assert.deepEqual(buildWildlifeIconModel({ rows: [...cells, row] }).rows, [
|
||||
row,
|
||||
]);
|
||||
});
|
||||
|
||||
test('screen buckets retain incumbent photos at their real sighting positions', () => {
|
||||
const incumbent = {
|
||||
...row,
|
||||
commonName: 'Incumbent',
|
||||
photo: { square: 'synthetic.jpg' },
|
||||
};
|
||||
const challenger = {
|
||||
...row,
|
||||
stableId: 'inat:2',
|
||||
commonName: 'Challenger',
|
||||
observedAt: Date.UTC(2026, 10, 4),
|
||||
};
|
||||
const distant = { ...row, stableId: 'inat:3', lon: row.lon + 1 };
|
||||
const model = buildWildlifeIconModel({
|
||||
rows: [challenger, incumbent, distant],
|
||||
preferredIds: [incumbent.stableId],
|
||||
projectRow: (item) => ({ x: (item.lon - row.lon) * 28 + 14, y: 14 }),
|
||||
});
|
||||
assert.deepEqual(model.rows, [incumbent, distant]);
|
||||
assert.equal(model.sightingCount, 3);
|
||||
assert.equal(model.rows[0].photo, incumbent.photo);
|
||||
});
|
||||
|
||||
test('visible observation counts precede per-area and global caps', () => {
|
||||
const rows = Array.from({ length: 6000 }, (_, i) => ({
|
||||
...row,
|
||||
stableId: `inat:${i}`,
|
||||
lon: -160 + i * 0.01,
|
||||
}));
|
||||
let projections = 0;
|
||||
const model = buildWildlifeIconModel({
|
||||
rows,
|
||||
projectRow(item) {
|
||||
projections++;
|
||||
return { x: (item.lon + 160) * 5600 + 14, y: 14 };
|
||||
},
|
||||
});
|
||||
assert.equal(projections, 6000);
|
||||
assert.equal(model.rows.length, 5000);
|
||||
assert.equal(model.totalPoints, 6000);
|
||||
assert.equal(model.saturated, true);
|
||||
assert.equal(model.sightingCount, 6000);
|
||||
});
|
||||
|
||||
test('selected observations win their bucket and survive a sample, viewport and projection change', () => {
|
||||
const selected = { ...row, commonName: 'Selected sighting' };
|
||||
const challenger = {
|
||||
...row,
|
||||
stableId: 'inat:2',
|
||||
commonName: 'Newer',
|
||||
observedAt: Date.UTC(2026, 10, 4),
|
||||
};
|
||||
const input = {
|
||||
selectedId: row.stableId,
|
||||
selectedRow: selected,
|
||||
rows: [challenger],
|
||||
limit: 1,
|
||||
};
|
||||
assert.deepEqual(
|
||||
buildWildlifeIconModel({ ...input, projectRow: () => ({ x: 14, y: 14 }) })
|
||||
.rows,
|
||||
[selected],
|
||||
);
|
||||
const offscreen = buildWildlifeIconModel({
|
||||
...input,
|
||||
boxes: [{ south: 0, west: 0, north: 1, east: 1 }],
|
||||
projectRow: () => null,
|
||||
});
|
||||
assert.deepEqual(offscreen.rows, [selected]);
|
||||
assert.equal(
|
||||
offscreen.sightingCount,
|
||||
0,
|
||||
'an offscreen pin is not a visible observation',
|
||||
);
|
||||
});
|
||||
|
||||
test('observation reduction is stable under input order, selected priority and visible filtering', () => {
|
||||
const named = { ...row, stableId: 'inat:2', commonName: 'Known bird' };
|
||||
const outside = { ...row, stableId: 'inat:3', lat: 0 };
|
||||
const input = {
|
||||
boxes: [{ south: 47, west: -123, north: 48, east: -122 }],
|
||||
limit: 1,
|
||||
};
|
||||
const model = buildWildlifeIconModel({
|
||||
...input,
|
||||
rows: [row, named, outside],
|
||||
});
|
||||
assert.equal(model.saturated, true);
|
||||
assert.equal(model.totalPoints, 2);
|
||||
assert.equal(model.sightingCount, 2);
|
||||
assert.deepEqual(model.rows, [named]);
|
||||
assert.deepEqual(
|
||||
buildWildlifeIconModel({ ...input, rows: [outside, named, row] }),
|
||||
model,
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildWildlifeIconModel({
|
||||
...input,
|
||||
rows: [named, row],
|
||||
selectedId: row.stableId,
|
||||
}).rows,
|
||||
[row],
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,115 @@
|
||||
/** Camera work runs on moveEnd/data arrival only. Measures remain observable
|
||||
* through PerformanceObserver while clearing entries prevents a long session
|
||||
* accumulating one timing record for every pan and streamed tile. */
|
||||
export const WILDLIFE_MOVE_END_BUDGET_MS = 4;
|
||||
const measureSpans = new Map();
|
||||
|
||||
function measureSpan(stage) {
|
||||
let span = measureSpans.get(stage);
|
||||
if (!span) {
|
||||
span = { name: `wildlife-sightings:${stage}`, depth: 0, frames: [] };
|
||||
measureSpans.set(stage, span);
|
||||
}
|
||||
const depth = span.depth++;
|
||||
if (!span.frames[depth]) {
|
||||
const start = `${span.name}:start:${depth}`;
|
||||
span.frames[depth] = { start, options: { start } };
|
||||
}
|
||||
return span;
|
||||
}
|
||||
|
||||
/** Measure a render phase without accumulating performance entries. */
|
||||
export function wildlifeMeasure(stage, work) {
|
||||
const clock = globalThis.performance;
|
||||
if (typeof clock?.mark !== 'function' || typeof clock?.measure !== 'function')
|
||||
return work();
|
||||
// Reuse names/options; nesting depth distinguishes simultaneous spans.
|
||||
const span = measureSpan(stage);
|
||||
const frame = span.frames[span.depth - 1];
|
||||
clock.mark(frame.start);
|
||||
try {
|
||||
return work();
|
||||
} finally {
|
||||
clock.measure(span.name, frame.options);
|
||||
clock.clearMarks?.(frame.start);
|
||||
clock.clearMeasures?.(span.name);
|
||||
span.depth--;
|
||||
}
|
||||
}
|
||||
|
||||
// A unit is at most 128 cells/billboards. The one-millisecond target leaves
|
||||
// room for the last unit, Cesium setters and the overlay host within 4ms.
|
||||
export const WILDLIFE_WORK_UNIT = 128;
|
||||
const WILDLIFE_WORK_SLICE_MS = 1;
|
||||
export const WILDLIFE_SYNC_CELL_LIMIT = 256;
|
||||
|
||||
function wildlifeWorkStep(work, stage = 'bucket') {
|
||||
return wildlifeMeasure(stage, () => work.next());
|
||||
}
|
||||
|
||||
/** Run bounded work synchronously for a small population. */
|
||||
export function drainWildlifeWork(work) {
|
||||
let stage = 'bucket';
|
||||
for (;;) {
|
||||
const step = wildlifeWorkStep(work, stage);
|
||||
if (step.done) return step.value;
|
||||
stage = step.value || stage;
|
||||
}
|
||||
}
|
||||
|
||||
/** A cancellable idle queue, never a frame listener. Superseding a camera view
|
||||
* drops unrun work. No network or requestAnimationFrame enters this queue. */
|
||||
export function createWildlifeWorkQueue({
|
||||
requestIdle = globalThis.requestIdleCallback?.bind(globalThis),
|
||||
cancelIdle = globalThis.cancelIdleCallback?.bind(globalThis),
|
||||
now = () => performance.now(),
|
||||
onSlice = () => {},
|
||||
} = {}) {
|
||||
let active = null;
|
||||
function cancel() {
|
||||
if (!active) return;
|
||||
const old = active;
|
||||
active = null;
|
||||
if (requestIdle) cancelIdle?.(old.handle);
|
||||
else clearTimeout(old.handle);
|
||||
old.work.return?.();
|
||||
}
|
||||
function start(work, complete) {
|
||||
cancel();
|
||||
const job = { work, handle: null, stage: 'bucket' };
|
||||
active = job;
|
||||
function schedule() {
|
||||
job.handle = requestIdle
|
||||
? requestIdle(run, { timeout: 32 })
|
||||
: setTimeout(run, 0);
|
||||
}
|
||||
function run() {
|
||||
if (active !== job) return;
|
||||
const began = now();
|
||||
let units = 0;
|
||||
let result;
|
||||
wildlifeMeasure('moveEnd:slice', () => {
|
||||
do {
|
||||
result = wildlifeWorkStep(work, job.stage);
|
||||
units++;
|
||||
if (result.done) {
|
||||
active = null;
|
||||
complete(result.value);
|
||||
break;
|
||||
}
|
||||
job.stage = result.value || job.stage;
|
||||
} while (units < 8 && now() - began < WILDLIFE_WORK_SLICE_MS);
|
||||
});
|
||||
onSlice({ startTimeMs: began, durationMs: now() - began, units });
|
||||
if (!result.done && active === job) schedule();
|
||||
}
|
||||
schedule();
|
||||
}
|
||||
return {
|
||||
start,
|
||||
cancel,
|
||||
get pending() {
|
||||
return active !== null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
harness,
|
||||
syntheticRows,
|
||||
changePositions,
|
||||
WILDLIFE_BENCHMARK_REFRESHES,
|
||||
} from '../../testSupport/wildlifeBillboardModel.mjs';
|
||||
|
||||
test('500 / 2,000 / 5,000 contacts write only position deltas and do no work for unchanged input', () => {
|
||||
for (const n of [500, 2000, 5000]) {
|
||||
const h = harness();
|
||||
let rows = syntheticRows(n);
|
||||
assert.equal(h.apply(rows).created, n);
|
||||
const retained = h.pointById.get(rows[1].stableId);
|
||||
for (let refresh = 0; refresh < WILDLIFE_BENCHMARK_REFRESHES; refresh++) {
|
||||
rows = changePositions(rows, refresh);
|
||||
assert.deepEqual(h.apply(rows), {
|
||||
created: 0,
|
||||
removed: 0,
|
||||
updated: n / 10,
|
||||
writes: n / 10,
|
||||
});
|
||||
assert.equal(h.pointById.get(rows[1].stableId), retained);
|
||||
}
|
||||
assert.equal(h.counters.created, n);
|
||||
assert.equal(h.counters.destroyed, 0);
|
||||
const before = { ...h.counters };
|
||||
assert.deepEqual(h.apply([...rows].reverse()), {
|
||||
created: 0,
|
||||
removed: 0,
|
||||
updated: 0,
|
||||
writes: 0,
|
||||
});
|
||||
assert.deepEqual(
|
||||
h.counters,
|
||||
before,
|
||||
'unchanged content writes and allocates nothing',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('ten 10% membership refreshes allocate and remove exactly their delta', (t) => {
|
||||
const n = 2000;
|
||||
const h = harness();
|
||||
let rows = syntheticRows(n);
|
||||
h.apply(rows);
|
||||
const retainedId = rows[1].stableId;
|
||||
const retained = h.pointById.get(retainedId);
|
||||
for (let refresh = 0; refresh < WILDLIFE_BENCHMARK_REFRESHES; refresh++) {
|
||||
rows = rows.map((row, i) =>
|
||||
i % 10 === 0 ? { ...row, stableId: `replacement:${refresh}:${i}` } : row,
|
||||
);
|
||||
assert.deepEqual(h.apply(rows), {
|
||||
created: n / 10,
|
||||
removed: n / 10,
|
||||
updated: 0,
|
||||
writes: (n / 10) * 5,
|
||||
});
|
||||
assert.equal(h.pointById.get(retainedId), retained);
|
||||
assert.equal(h.points.size, n);
|
||||
}
|
||||
assert.equal(h.counters.created, n + (n / 10) * WILDLIFE_BENCHMARK_REFRESHES);
|
||||
assert.equal(h.counters.destroyed, (n / 10) * WILDLIFE_BENCHMARK_REFRESHES);
|
||||
t.diagnostic(
|
||||
`N=${n}, 10 refreshes: initial creates=${n}, delta creates=2000, delta removes=2000, retained billboard identity unchanged`,
|
||||
);
|
||||
});
|
||||
|
||||
test('count, grade and name edits produce no billboard writes or resampling', () => {
|
||||
const h = harness();
|
||||
const rows = syntheticRows(500);
|
||||
h.model.update(rows);
|
||||
const before = { ...h.counters };
|
||||
assert.deepEqual(
|
||||
h.model.update(
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
count: 1000,
|
||||
quality: 'needs_id',
|
||||
commonName: 'Synthetic name',
|
||||
})),
|
||||
),
|
||||
{ created: 0, removed: 0, updated: 0, writes: 0 },
|
||||
);
|
||||
assert.deepEqual(h.counters, before);
|
||||
});
|
||||
|
||||
import { PerformanceObserver } from 'node:perf_hooks';
|
||||
import {
|
||||
denseContinentalRendering,
|
||||
DENSE_CONTINENT_CELLS,
|
||||
DENSE_CONTINENT_ROWS,
|
||||
} from './renderPerformanceFixtures.mjs';
|
||||
import {
|
||||
WILDLIFE_MOVE_END_BUDGET_MS,
|
||||
WILDLIFE_WORK_UNIT,
|
||||
createWildlifeWorkQueue,
|
||||
} from './performance.js';
|
||||
|
||||
test('dense continental moveEnd has a deterministic linear operation budget and reuses decoded metadata/coordinates', () => {
|
||||
const h = denseContinentalRendering();
|
||||
try {
|
||||
assert.equal(
|
||||
h.moveEnd(),
|
||||
'pending',
|
||||
'dense work must yield before blocking camera input',
|
||||
);
|
||||
assert.equal(
|
||||
h.metrics.projections,
|
||||
0,
|
||||
'moveEnd enqueues work without processing a tile',
|
||||
);
|
||||
const cold = h.flush();
|
||||
assert.ok(
|
||||
cold.glowPrimitives > 4000 &&
|
||||
cold.billboards > 50 &&
|
||||
cold.billboards <= DENSE_CONTINENT_ROWS,
|
||||
'benchmark exercises a dense glow with only real sampled sighting badges',
|
||||
);
|
||||
assert.equal(
|
||||
h.state.renderPipelineOperations.densityVisits,
|
||||
DENSE_CONTINENT_CELLS,
|
||||
);
|
||||
assert.equal(
|
||||
h.state.renderPipelineOperations.densityMetadataBuilds,
|
||||
DENSE_CONTINENT_CELLS,
|
||||
);
|
||||
assert.ok(cold.slices.length > 1);
|
||||
for (let move = 1; move <= 4; move++) {
|
||||
h.moveEnd(move * 3);
|
||||
const result = h.flush();
|
||||
const ops = h.state.renderPipelineOperations;
|
||||
assert.equal(ops.densityVisits, DENSE_CONTINENT_CELLS);
|
||||
assert.equal(ops.observationVisits, DENSE_CONTINENT_ROWS);
|
||||
assert.equal(ops.densityMetadataBuilds, 0);
|
||||
assert.equal(ops.densityWrites, 0);
|
||||
assert.equal(ops.densityPositionBuilds, 0);
|
||||
assert.ok(ops.densityRetirementVisits <= DENSE_CONTINENT_CELLS);
|
||||
assert.equal(ops.densityCreated, 0);
|
||||
assert.equal(ops.densityRemoved, 0);
|
||||
assert.equal(
|
||||
result.cartesianBuilds,
|
||||
0,
|
||||
'no repeated longitude/latitude trig per camera pass',
|
||||
);
|
||||
assert.ok(ops.projections <= DENSE_CONTINENT_ROWS);
|
||||
assert.ok(result.projections <= DENSE_CONTINENT_ROWS + 150);
|
||||
assert.ok(
|
||||
ops.billboardVisits <= result.billboards * 3 + WILDLIFE_WORK_UNIT,
|
||||
);
|
||||
assert.equal(
|
||||
ops.rankComparisons,
|
||||
0,
|
||||
'continental buckets never sort the source population',
|
||||
);
|
||||
assert.ok(ops.spacingTests <= result.billboards * 9);
|
||||
assert.ok(result.slices.length <= 144);
|
||||
assert.ok(result.slices.every((slice) => slice.units <= 8));
|
||||
assert.equal(
|
||||
result.publications,
|
||||
3,
|
||||
'publish overlays once after the accepted view is ready',
|
||||
);
|
||||
}
|
||||
h.moveEnd(12);
|
||||
h.flush();
|
||||
assert.equal(h.state.renderPipelineOperations.billboardWrites, 0);
|
||||
assert.equal(
|
||||
h.state.renderPipelineOperations.billboardVisits,
|
||||
h.state.renderedRows.length * 2,
|
||||
'the delta owner scans each row and retained billboard once',
|
||||
);
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('superseded dense camera work is dropped and idle callbacks are removed at teardown', () => {
|
||||
const h = denseContinentalRendering();
|
||||
assert.equal(h.moveEnd(0), 'pending');
|
||||
assert.equal(h.moveEnd(12), 'pending');
|
||||
const result = h.flush();
|
||||
assert.equal(
|
||||
h.state.renderPipelineOperations.densityVisits,
|
||||
DENSE_CONTINENT_CELLS,
|
||||
);
|
||||
assert.equal(result.publications, 3);
|
||||
assert.equal(h.moveEnd(24), 'pending');
|
||||
h.destroy();
|
||||
assert.equal(h.flush().slices.length, 0);
|
||||
});
|
||||
|
||||
test('performance marks cover each render phase and leave no accumulated entries', async () => {
|
||||
const observed = [];
|
||||
const observer = new PerformanceObserver((list) =>
|
||||
observed.push(...list.getEntries()),
|
||||
);
|
||||
observer.observe({ entryTypes: ['measure'] });
|
||||
const h = denseContinentalRendering();
|
||||
try {
|
||||
h.moveEnd();
|
||||
h.flush();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
for (const phase of [
|
||||
'density',
|
||||
'bucket',
|
||||
'rank',
|
||||
'billboard-update',
|
||||
'overlay-publish',
|
||||
'moveEnd:slice',
|
||||
])
|
||||
assert.ok(
|
||||
observed.some((entry) => entry.name === `wildlife-sightings:${phase}`),
|
||||
phase,
|
||||
);
|
||||
assert.deepEqual(
|
||||
performance
|
||||
.getEntriesByType('mark')
|
||||
.filter((entry) => entry.name.startsWith('wildlife-sightings:')),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
performance
|
||||
.getEntriesByType('measure')
|
||||
.filter((entry) => entry.name.startsWith('wildlife-sightings:')),
|
||||
[],
|
||||
);
|
||||
assert.equal(WILDLIFE_MOVE_END_BUDGET_MS, 4);
|
||||
} finally {
|
||||
observer.disconnect();
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('idle work has a fixed unit ceiling even when the clock does not advance', () => {
|
||||
const callbacks = new Map();
|
||||
let id = 0;
|
||||
let slices = 0;
|
||||
let done = false;
|
||||
const queue = createWildlifeWorkQueue({
|
||||
requestIdle(fn) {
|
||||
callbacks.set(++id, fn);
|
||||
return id;
|
||||
},
|
||||
cancelIdle(handle) {
|
||||
callbacks.delete(handle);
|
||||
},
|
||||
now: () => 0,
|
||||
onSlice({ units }) {
|
||||
assert.ok(units <= 8);
|
||||
slices++;
|
||||
},
|
||||
});
|
||||
function* work() {
|
||||
for (let i = 0; i < 20; i++) yield 'bucket';
|
||||
return 42;
|
||||
}
|
||||
queue.start(work(), (result) => {
|
||||
assert.equal(result, 42);
|
||||
done = true;
|
||||
});
|
||||
assert.equal(done, false);
|
||||
while (callbacks.size) {
|
||||
const [handle, fn] = callbacks.entries().next().value;
|
||||
callbacks.delete(handle);
|
||||
fn();
|
||||
}
|
||||
assert.equal(done, true);
|
||||
assert.equal(slices, 3);
|
||||
assert.equal(queue.pending, false);
|
||||
});
|
||||
|
||||
test('returning to a previous camera after partially applying a billboard delta still reconciles the collection', (t) => {
|
||||
// Slices end after eight units or 1 ms. A clock that advances 2 ms per read
|
||||
// makes every slice one unit, so the half-applied delta is observable.
|
||||
let clock = 0;
|
||||
t.mock.method(performance, 'now', () => (clock += 2));
|
||||
const h = denseContinentalRendering();
|
||||
try {
|
||||
h.moveEnd(0);
|
||||
h.flush();
|
||||
const originalIds = [...h.state.pointById.keys()].sort();
|
||||
const membership = () => [...h.state.pointById.keys()].sort();
|
||||
h.moveEnd(12);
|
||||
// Advance just until real billboard mutations begin, then supersede the
|
||||
// partially applied view by returning to the original camera.
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
h.flush(1);
|
||||
if (JSON.stringify(membership()) !== JSON.stringify(originalIds)) break;
|
||||
}
|
||||
assert.notDeepEqual(membership(), originalIds);
|
||||
h.moveEnd(0);
|
||||
h.flush();
|
||||
assert.deepEqual(membership(), originalIds);
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/** Compact card ages in translated minutes, hours or days. */
|
||||
export function formatAge(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return null;
|
||||
const hours = Math.floor(deltaMs / 3_600_000);
|
||||
if (hours < 1)
|
||||
return t('{count}m', {
|
||||
context: 'age',
|
||||
count: Math.max(1, Math.floor(deltaMs / 60_000)),
|
||||
});
|
||||
if (hours < 24) return t('{count}h', { context: 'age', count: hours });
|
||||
return t('{count}d', { context: 'age', count: Math.floor(hours / 24) });
|
||||
}
|
||||
|
||||
/** Use the creator portion of the supplied attribution, never a guessed
|
||||
* observer/login. iNaturalist appends rights and licence text after it. */
|
||||
export function photoCreator(photo) {
|
||||
if (typeof photo?.attribution !== 'string') return null;
|
||||
const creator = photo.attribution
|
||||
.replace(/^\s*(?:\(c\)|©|copyright)\s*/i, '')
|
||||
.replace(/,\s*(?:some|no|all)\s+rights\s+reserved\b.*$/i, '')
|
||||
.replace(
|
||||
/(?:,\s*|\s*\()(?:CC[\s-]+BY(?:[\s-]+(?:NC|SA|ND))*|CC0|public\s+domain)(?:\s+\d(?:\.\d)?)?\)?\s*$/i,
|
||||
'',
|
||||
)
|
||||
.trim();
|
||||
if (
|
||||
!creator ||
|
||||
/^(?:(?:some|no|all)\s+rights\s+reserved\b|CC(?:0|[\s-]+BY)\b)/i.test(
|
||||
creator,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return creator;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { ambientScore } from './cards.js';
|
||||
import { compareWildlifeIds } from './model.js';
|
||||
import { WILDLIFE_PHOTO_FETCH, WILDLIFE_PHOTO_CACHE_MAX } from './policy.js';
|
||||
|
||||
/** Own decoded ambient images and their cancellable, ranked queue. */
|
||||
export function createPhotoSlots({
|
||||
imageFactory = () => new Image(),
|
||||
now = () => Date.now(),
|
||||
setTimer = (fn, delay) => setTimeout(fn, delay),
|
||||
clearTimer = (timer) => clearTimeout(timer),
|
||||
onChange = null,
|
||||
} = {}) {
|
||||
const slots = new Map();
|
||||
let queue = [];
|
||||
let timer = null;
|
||||
let destroyed = false;
|
||||
let liveKeys = new Set();
|
||||
let cohortKey = null;
|
||||
|
||||
function notify() {
|
||||
if (!destroyed) onChange?.();
|
||||
}
|
||||
|
||||
function release(slot) {
|
||||
slot.cancelled = true;
|
||||
if (!slot.image) return;
|
||||
slot.image.onload = null;
|
||||
slot.image.onerror = null;
|
||||
// Detaching listeners alone would leave pending network and decoded data.
|
||||
slot.image.src = '';
|
||||
}
|
||||
|
||||
function prune() {
|
||||
if (slots.size <= WILDLIFE_PHOTO_CACHE_MAX) return;
|
||||
const ranked = [...slots].sort(([a, slotA], [b, slotB]) => {
|
||||
const keepA = liveKeys.has(a);
|
||||
const keepB = liveKeys.has(b);
|
||||
const frameA = slotA.displayed;
|
||||
const frameB = slotB.displayed;
|
||||
// Decoded photographs that have actually been painted outrank unseen
|
||||
// prefetches. Preserve their Image objects for a flash-free return pan.
|
||||
return Number(keepA) - Number(keepB) || Number(frameA) - Number(frameB);
|
||||
});
|
||||
for (const [key, slot] of ranked) {
|
||||
if (slots.size <= WILDLIFE_PHOTO_CACHE_MAX) break;
|
||||
release(slot);
|
||||
slots.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function ensure(row) {
|
||||
const url = row?.photo?.square;
|
||||
if (!url) return null;
|
||||
const key = row.stableId;
|
||||
let slot = slots.get(key);
|
||||
if (slot && slot.url !== url) {
|
||||
release(slot);
|
||||
slots.delete(key);
|
||||
slot = null;
|
||||
}
|
||||
if (!slot) {
|
||||
slot = {
|
||||
key,
|
||||
url,
|
||||
image: null,
|
||||
state: 'queued',
|
||||
cancelled: false,
|
||||
displayed: false,
|
||||
};
|
||||
} else slots.delete(key);
|
||||
slots.set(key, slot);
|
||||
return slot;
|
||||
}
|
||||
|
||||
function launch(slot) {
|
||||
if (
|
||||
destroyed ||
|
||||
slot.cancelled ||
|
||||
slots.get(slot.key) !== slot ||
|
||||
slot.state !== 'queued'
|
||||
)
|
||||
return;
|
||||
slot.state = 'loading';
|
||||
let image;
|
||||
try {
|
||||
image = imageFactory();
|
||||
slot.image = image;
|
||||
// Deliberately non-CORS: the overlay canvas is display-only and is
|
||||
// never read back. Viewport capture reads the separate Cesium canvas.
|
||||
image.onload = () => {
|
||||
if (destroyed || slot.cancelled || slots.get(slot.key) !== slot) return;
|
||||
slot.state = 'ready';
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
notify();
|
||||
};
|
||||
image.onerror = () => {
|
||||
if (destroyed || slot.cancelled || slots.get(slot.key) !== slot) return;
|
||||
slot.state = 'error';
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
notify();
|
||||
};
|
||||
image.src = slot.url;
|
||||
} catch {
|
||||
slot.state = 'error';
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
function pace() {
|
||||
timer = null;
|
||||
if (destroyed) return;
|
||||
while (queue.length) {
|
||||
const slot = queue.shift();
|
||||
if (slot.cancelled || slots.get(slot.key) !== slot) continue;
|
||||
launch(slot);
|
||||
break;
|
||||
}
|
||||
if (queue.length) timer = setTimer(pace, WILDLIFE_PHOTO_FETCH.intervalMs);
|
||||
}
|
||||
|
||||
function setAmbient(rows) {
|
||||
if (destroyed) return;
|
||||
const ranked = [...rows].sort(
|
||||
(a, b) =>
|
||||
ambientScore(b, now()) - ambientScore(a, now()) ||
|
||||
compareWildlifeIds(a.stableId, b.stableId),
|
||||
);
|
||||
const nextKey = JSON.stringify(
|
||||
ranked.map((row) => [row.stableId, row.photo?.square]),
|
||||
);
|
||||
// A same-cohort refresh must not turn the paced tail into another burst.
|
||||
if (cohortKey === nextKey) return;
|
||||
cohortKey = nextKey;
|
||||
clearTimer(timer);
|
||||
timer = null;
|
||||
liveKeys = new Set(ranked.map((row) => row.stableId));
|
||||
queue = ranked.map(ensure).filter(Boolean);
|
||||
prune();
|
||||
queue = queue.filter(
|
||||
(slot) => slots.get(slot.key) === slot && slot.state === 'queued',
|
||||
);
|
||||
for (
|
||||
let index = 0;
|
||||
index < WILDLIFE_PHOTO_FETCH.burst && queue.length;
|
||||
index++
|
||||
)
|
||||
launch(queue.shift());
|
||||
if (queue.length) timer = setTimer(pace, WILDLIFE_PHOTO_FETCH.intervalMs);
|
||||
}
|
||||
|
||||
function get(row) {
|
||||
const key = row?.stableId;
|
||||
const slot = slots.get(key);
|
||||
if (!slot || slot.state !== 'ready') return null;
|
||||
slot.displayed = true;
|
||||
slots.delete(key);
|
||||
slots.set(key, slot);
|
||||
return slot.image;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
clearTimer(timer);
|
||||
timer = null;
|
||||
queue = [];
|
||||
for (const slot of slots.values()) release(slot);
|
||||
slots.clear();
|
||||
liveKeys.clear();
|
||||
cohortKey = null;
|
||||
}
|
||||
|
||||
return {
|
||||
setAmbient,
|
||||
get,
|
||||
clear,
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
clear();
|
||||
},
|
||||
diagnostics: () => ({ slots: slots.size, queued: queue.length }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createPhotoSlots } from './photos.js';
|
||||
|
||||
function clock() {
|
||||
let time = 0;
|
||||
let next = 0;
|
||||
const timers = new Map();
|
||||
return {
|
||||
now: () => time,
|
||||
setTimer(fn, delay) {
|
||||
const id = ++next;
|
||||
timers.set(id, { fn, at: time + delay });
|
||||
return id;
|
||||
},
|
||||
clearTimer(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
tick(ms) {
|
||||
const until = time + ms;
|
||||
while (true) {
|
||||
const pending = [...timers].sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!pending || pending[1].at > until) break;
|
||||
time = pending[1].at;
|
||||
timers.delete(pending[0]);
|
||||
pending[1].fn();
|
||||
}
|
||||
time = until;
|
||||
},
|
||||
count: () => timers.size,
|
||||
};
|
||||
}
|
||||
|
||||
const row = (index) => ({
|
||||
stableId: `inat:${index}`,
|
||||
quality: 'research',
|
||||
observedAt: 0,
|
||||
favesCount: 100 - index,
|
||||
identificationAgreements: 0,
|
||||
photo: {
|
||||
square: `https://inaturalist-open-data.s3.amazonaws.com/photos/${index}/square.jpg`,
|
||||
medium: `https://inaturalist-open-data.s3.amazonaws.com/photos/${index}/medium.jpg`,
|
||||
licence: 'cc-by',
|
||||
attribution: '(c) Synthetic Photographer',
|
||||
},
|
||||
});
|
||||
|
||||
function harness() {
|
||||
const time = clock();
|
||||
const launches = [];
|
||||
const images = [];
|
||||
let changes = 0;
|
||||
const slots = createPhotoSlots({
|
||||
...time,
|
||||
onChange: () => changes++,
|
||||
imageFactory: () => {
|
||||
const image = {
|
||||
set src(url) {
|
||||
this.url = url;
|
||||
if (url) launches.push({ at: time.now(), url });
|
||||
},
|
||||
};
|
||||
images.push(image);
|
||||
return image;
|
||||
},
|
||||
});
|
||||
return { slots, time, images, launches, changes: () => changes };
|
||||
}
|
||||
|
||||
test('24 photos start in rank order: eight immediately, then one per 250 ms', () => {
|
||||
const h = harness();
|
||||
const rows = Array.from({ length: 24 }, (_, index) => row(index + 1));
|
||||
h.slots.setAmbient([...rows].reverse());
|
||||
assert.equal(h.launches.length, 8);
|
||||
h.time.tick(249);
|
||||
assert.equal(h.launches.length, 8);
|
||||
h.time.tick(1);
|
||||
assert.equal(h.launches.length, 9);
|
||||
h.time.tick(3750);
|
||||
assert.equal(h.launches.length, 24);
|
||||
assert.deepEqual(
|
||||
h.launches.map(({ url }) => Number(url.match(/photos\/(\d+)/)[1])),
|
||||
rows.map((_, index) => index + 1),
|
||||
);
|
||||
assert.deepEqual(
|
||||
h.launches.slice(8).map(({ at }) => at),
|
||||
Array.from({ length: 16 }, (_, i) => (i + 1) * 250),
|
||||
);
|
||||
h.slots.destroy();
|
||||
});
|
||||
|
||||
test('same-cohort refresh keeps the ambient queue pacing without restarting its burst', () => {
|
||||
const h = harness();
|
||||
const rows = Array.from({ length: 24 }, (_, i) => row(i + 1));
|
||||
h.slots.setAmbient(rows);
|
||||
assert.equal(h.launches.length, 8);
|
||||
h.time.tick(100);
|
||||
h.slots.setAmbient(rows);
|
||||
assert.equal(h.launches.length, 8);
|
||||
h.time.tick(150);
|
||||
assert.equal(h.launches.length, 9);
|
||||
assert.ok(h.launches.every(({ url }) => /square.jpg$/.test(url)));
|
||||
h.slots.destroy();
|
||||
});
|
||||
|
||||
test('images settle through slots, are pruned to 96, and late loads cannot publish after clear', () => {
|
||||
const h = harness();
|
||||
h.slots.setAmbient([row(1)]);
|
||||
const loaded = h.images[0];
|
||||
loaded.onload();
|
||||
assert.equal(h.slots.get(row(1)), loaded);
|
||||
assert.equal(h.changes(), 1);
|
||||
for (let index = 2; index <= 130; index++) h.slots.setAmbient([row(index)]);
|
||||
assert.equal(h.slots.diagnostics().slots, 96);
|
||||
const last = h.images.at(-1);
|
||||
const late = last.onload;
|
||||
h.slots.clear();
|
||||
late();
|
||||
assert.equal(h.changes(), 1);
|
||||
assert.equal(h.slots.diagnostics().slots, 0);
|
||||
assert.equal(h.time.count(), 0);
|
||||
assert.ok(h.images.every((image) => image.url === ''));
|
||||
});
|
||||
|
||||
test('returning cards reuse their decoded frame after a pan or height-band hide', () => {
|
||||
const h = harness();
|
||||
const first = row(1);
|
||||
h.slots.setAmbient([first]);
|
||||
const frame = h.images[0];
|
||||
frame.onload();
|
||||
assert.equal(h.slots.get(first), frame);
|
||||
h.slots.setAmbient([]);
|
||||
h.slots.setAmbient([row(2)]);
|
||||
h.images[1].onload();
|
||||
for (let index = 3; index < 105; index++) h.slots.setAmbient([row(index)]);
|
||||
h.slots.setAmbient([first]);
|
||||
assert.equal(
|
||||
h.slots.get(first),
|
||||
frame,
|
||||
'the returning image is already ready, with no empty frame',
|
||||
);
|
||||
assert.equal(
|
||||
h.launches.filter(({ url }) => url.includes('/photos/1/')).length,
|
||||
1,
|
||||
);
|
||||
h.slots.destroy();
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { msg } from '../../i18n/marker.js';
|
||||
|
||||
export const WILDLIFE_UNKNOWN_COLOR = '#9aa7b3';
|
||||
export const WILDLIFE_ACCENT = '#7bd88f';
|
||||
export const ICONIC_TINTS = Object.freeze({
|
||||
Aves: '#9fa8ff',
|
||||
Mammalia: '#e0a96d',
|
||||
Reptilia: '#c5d86d',
|
||||
Amphibia: '#5fd3bc',
|
||||
Actinopterygii: '#6fa8dc',
|
||||
Insecta: '#f0b35a',
|
||||
Arachnida: '#d98c6a',
|
||||
Plantae: WILDLIFE_ACCENT,
|
||||
Fungi: '#e8a0a8',
|
||||
Animalia: '#b0bec5',
|
||||
Unknown: WILDLIFE_UNKNOWN_COLOR,
|
||||
});
|
||||
|
||||
export const ICONIC_LEGEND_ORDER = Object.freeze([
|
||||
'Aves',
|
||||
'Mammalia',
|
||||
'Reptilia',
|
||||
'Amphibia',
|
||||
'Actinopterygii',
|
||||
'Insecta',
|
||||
'Arachnida',
|
||||
'Plantae',
|
||||
'Fungi',
|
||||
'Animalia',
|
||||
'Unknown',
|
||||
]);
|
||||
|
||||
// Keep badges compact among dense anchors, labels and photo cards.
|
||||
export const WILDLIFE_SCALE_BY_DISTANCE = Object.freeze([
|
||||
1_000, 1.1, 8_000_000, 0.6,
|
||||
]);
|
||||
export const WILDLIFE_SPRITE_PX = 20;
|
||||
export const WILDLIFE_BADGE_MAX_PX =
|
||||
WILDLIFE_SPRITE_PX * WILDLIFE_SCALE_BY_DISTANCE[1];
|
||||
|
||||
export const WILDLIFE_GRID_HEIGHT_METERS = 400_000;
|
||||
// Bound billboard population after screen bucketing.
|
||||
export const WILDLIFE_RENDER_LIMIT = 5_000;
|
||||
// Bound candidates before the overlay host's final declutter.
|
||||
export const WILDLIFE_LABEL_LIMIT = 150;
|
||||
export const WILDLIFE_LABEL_NAME_SEPARATION_PX = 80;
|
||||
// Hidden ground batches may need several render passes to compile. Retire a
|
||||
// failed staging batch after a bounded number instead of holding rendering.
|
||||
export const WILDLIFE_GROUND_READY_MAX_CHECKS = 16;
|
||||
|
||||
// Camera-height thresholds use +/-10% hysteresis to keep representations still
|
||||
// while a camera settles near a boundary.
|
||||
export const WILDLIFE_HEIGHT_HYSTERESIS = 0.1;
|
||||
export const WILDLIFE_HEIGHT_BANDS = Object.freeze({
|
||||
global: 3_000_000,
|
||||
cells: 1_500_000,
|
||||
cards: 400_000,
|
||||
});
|
||||
export const WILDLIFE_MAX_ROWS = 2_000;
|
||||
export const WILDLIFE_CARD_LIMIT = 24;
|
||||
export const WILDLIFE_CARD_MIN_SEP_PX = 112;
|
||||
export const WILDLIFE_CARD_REGION_COLUMNS = 4;
|
||||
export const WILDLIFE_CARD_REGION_ROWS = 3;
|
||||
export const WILDLIFE_PHOTO_CACHE_MAX = 96;
|
||||
|
||||
// Static S3 thumbnails do not spend the API quota.
|
||||
export const WILDLIFE_PHOTO_FETCH = Object.freeze({
|
||||
burst: 8,
|
||||
intervalMs: 250,
|
||||
});
|
||||
|
||||
export const WILDLIFE_DEBOUNCE_MS = 600;
|
||||
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;
|
||||
|
||||
export const WILDLIFE_CARD_SCALE_FULL_METERS = 20_000;
|
||||
export const WILDLIFE_CARD_SCALE_MID_METERS = 120_000;
|
||||
export const WILDLIFE_CARD_SCALE_END_METERS = 125_000;
|
||||
// Separate entry and exit thresholds prevent churn during small zooms.
|
||||
export const WILDLIFE_CARD_SHOW_MAX_METERS = 110_000;
|
||||
export const WILDLIFE_CARD_HIDE_MAX_METERS = 125_000;
|
||||
export const WILDLIFE_CARD_FADE_LOW_METERS = 70_000;
|
||||
export const WILDLIFE_CARD_FADE_HIGH_METERS = 125_000;
|
||||
export const WILDLIFE_SELECTED_LABEL_MAX_CHARS = 40;
|
||||
export const WILDLIFE_SELECTED_LABEL_FADE_LOW_METERS = 250_000;
|
||||
export const WILDLIFE_SELECTED_LABEL_FADE_HIGH_METERS = 400_000;
|
||||
export const WILDLIFE_CARD_MAX_DISTANCE_METERS = 600_000;
|
||||
export const WILDLIFE_CARD_ALTITUDE_SCALE = Object.freeze({
|
||||
fullEnd: WILDLIFE_CARD_SCALE_FULL_METERS,
|
||||
midEnd: WILDLIFE_CARD_SCALE_MID_METERS,
|
||||
end: WILDLIFE_CARD_SCALE_END_METERS,
|
||||
midValue: 0.9,
|
||||
endValue: 0.85,
|
||||
smoothToMid: true,
|
||||
});
|
||||
|
||||
export { ACARTIA_GUARD_BOX } from './acartiaTaxa.js';
|
||||
export const WILDLIFE_CONFIRMED_CHIP_ID = 'quality-confirmed';
|
||||
|
||||
export const TAXA_PRESETS = Object.freeze([
|
||||
Object.freeze({
|
||||
value: 'all',
|
||||
id: 'taxa-all',
|
||||
label: msg('ALL'),
|
||||
title: msg('Show all taxa'),
|
||||
}),
|
||||
Object.freeze({
|
||||
value: 'Aves',
|
||||
id: 'taxa-birds',
|
||||
label: msg('BIRDS'),
|
||||
title: msg('Show only birds'),
|
||||
}),
|
||||
Object.freeze({
|
||||
value: 'Mammalia',
|
||||
id: 'taxa-mammals',
|
||||
label: msg('MAMMALS'),
|
||||
title: msg('Show only mammals'),
|
||||
}),
|
||||
Object.freeze({
|
||||
value: 'whales',
|
||||
id: 'taxa-whales',
|
||||
label: msg('WHALES'),
|
||||
title: msg('Show only whales and dolphins (7 days)'),
|
||||
}),
|
||||
Object.freeze({
|
||||
value: 'Insecta',
|
||||
id: 'taxa-insects',
|
||||
label: msg('INSECTS'),
|
||||
title: msg('Show only insects'),
|
||||
}),
|
||||
Object.freeze({
|
||||
value: 'Plantae',
|
||||
id: 'taxa-plants',
|
||||
label: msg('PLANTS'),
|
||||
title: msg('Show only plants'),
|
||||
}),
|
||||
]);
|
||||
@@ -0,0 +1,341 @@
|
||||
// The server imports this module. Keep the projection independent of Cesium,
|
||||
// DOM and the translation catalog, and never copy an upstream record wholesale.
|
||||
import { msg } from '../../i18n/marker.js';
|
||||
import { acartiaSpeciesGroup, inAcartiaCoverage } from './acartiaTaxa.js';
|
||||
|
||||
const OPEN_PHOTO_LICENCES = Object.freeze([
|
||||
'cc0',
|
||||
'cc-by',
|
||||
'cc-by-sa',
|
||||
'cc-by-nc',
|
||||
'cc-by-nc-sa',
|
||||
]);
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const ACARTIA_MAX_AGE_MS = 8 * DAY_MS;
|
||||
const OBSERVATION_FUTURE_TOLERANCE_MS = 10 * 60_000;
|
||||
// A JavaScript $ can match before a final newline; require the true end too.
|
||||
const INAT_PHOTO =
|
||||
/^https:\/\/inaturalist-open-data\.s3\.amazonaws\.com\/photos\/\d+\/square\.(jpe?g|png)$(?![\s\S])/i;
|
||||
const INAT_OBSERVATION =
|
||||
/^https:\/\/www\.inaturalist\.org\/observations\/\d+$(?![\s\S])/;
|
||||
|
||||
/** Accept only canonical iNaturalist observation pages, without URL suffixes. */
|
||||
function inatObservationUri(value) {
|
||||
return typeof value === 'string' && INAT_OBSERVATION.test(value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function cleanText(value, maxLength) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const text = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim();
|
||||
return text ? text.slice(0, maxLength) : null;
|
||||
}
|
||||
|
||||
function numberOrNull(value) {
|
||||
if (typeof value !== 'number' && typeof value !== 'string') return null;
|
||||
if (typeof value === 'string' && !value.trim()) return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function validPosition(lat, lon) {
|
||||
return (
|
||||
Number.isFinite(lat) &&
|
||||
Math.abs(lat) <= 90 &&
|
||||
Number.isFinite(lon) &&
|
||||
Math.abs(lon) <= 180
|
||||
);
|
||||
}
|
||||
|
||||
function dateOnly(value) {
|
||||
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value))
|
||||
return null;
|
||||
const time = Date.parse(value);
|
||||
return Number.isFinite(time) &&
|
||||
new Date(time).toISOString().slice(0, 10) === value
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Date-only records cannot prove a time within a partly expired cutoff day.
|
||||
* Use its start conservatively, including for privacy-preserving cached cells. */
|
||||
function withinObservationWindow(observedAt, observedOn, nowMs, windowMs) {
|
||||
const time = Number.isFinite(observedAt)
|
||||
? observedAt
|
||||
: dateOnly(observedOn)
|
||||
? Date.parse(`${observedOn}T00:00:00Z`)
|
||||
: NaN;
|
||||
return (
|
||||
Number.isFinite(time) &&
|
||||
Number.isFinite(nowMs) &&
|
||||
Number.isFinite(windowMs) &&
|
||||
windowMs >= 0 &&
|
||||
time >= nowMs - windowMs &&
|
||||
time <= nowMs + OBSERVATION_FUTURE_TOLERANCE_MS
|
||||
);
|
||||
}
|
||||
|
||||
/** Apply the displayed observation window to projected and retained LRU rows. */
|
||||
export function isRecentWildlifeSighting(row, nowMs, taxa = 'all') {
|
||||
return withinObservationWindow(
|
||||
row?.observedAt,
|
||||
row?.observedOn,
|
||||
nowMs,
|
||||
(taxa === 'whales' ? 7 : 2) * DAY_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** Strictly parse the two documented Acartia forms. A timezone-free SQL-style
|
||||
* value is UTC; ISO timestamps must carry a timezone rather than using the host's. */
|
||||
export function parseAcartiaTime(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const text = value.trim();
|
||||
const sql = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text);
|
||||
const iso =
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i.test(
|
||||
text,
|
||||
);
|
||||
if (!sql && !iso) return null;
|
||||
const day = dateOnly(text.slice(0, 10));
|
||||
if (!day) return null;
|
||||
const [hour, minute, second] = text.slice(11, 19).split(':').map(Number);
|
||||
if (hour > 23 || minute > 59 || second > 59) return null;
|
||||
const time = Date.parse(sql ? `${text.replace(' ', 'T')}Z` : text);
|
||||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
|
||||
function coarsen(value, precision = 2) {
|
||||
const scale = 10 ** precision;
|
||||
return Math.round(value * scale) / scale;
|
||||
}
|
||||
|
||||
/** Public iNaturalist obscuration is a 0.2-degree cell aligned to zero. The
|
||||
* rounding removes floating-point noise, including at negative longitudes. */
|
||||
export function obscuredCell(lat, lon) {
|
||||
if (!validPosition(lat, lon)) return null;
|
||||
const south = Math.min(89.8, Math.floor(lat * 5) / 5);
|
||||
const west = Math.min(179.8, Math.floor(lon * 5) / 5);
|
||||
return {
|
||||
south: coarsen(south, 1),
|
||||
north: coarsen(south + 0.2, 1),
|
||||
west: coarsen(west, 1),
|
||||
east: coarsen(west + 0.2, 1),
|
||||
};
|
||||
}
|
||||
|
||||
/** Only the documented open-data square URL can be transformed to a display size. */
|
||||
export function inatPhotoUrl(url, size = 'square') {
|
||||
if (typeof url !== 'string' || !INAT_PHOTO.test(url)) return null;
|
||||
if (size !== 'square' && size !== 'medium') return null;
|
||||
return size === 'square' ? url : url.replace(/\/square\./i, `/${size}.`);
|
||||
}
|
||||
|
||||
/** Accept either the first upstream photo or the explicitly projected photo. */
|
||||
export function isOpenPhoto(photo) {
|
||||
if (!photo || typeof photo !== 'object') return false;
|
||||
const licence = photo.license_code ?? photo.licence;
|
||||
return (
|
||||
OPEN_PHOTO_LICENCES.includes(licence) &&
|
||||
inatPhotoUrl(photo.url ?? photo.square) !== null &&
|
||||
cleanText(photo.attribution, 160) !== null
|
||||
);
|
||||
}
|
||||
|
||||
/** Licence codes remain provider data; the all-rights-reserved fallback is
|
||||
* marked for translation when cards display it. */
|
||||
export function inatLicenceLabel(code) {
|
||||
switch (code) {
|
||||
case 'cc0':
|
||||
return 'CC0';
|
||||
case 'cc-by':
|
||||
return 'CC BY';
|
||||
case 'cc-by-sa':
|
||||
return 'CC BY-SA';
|
||||
case 'cc-by-nc':
|
||||
return 'CC BY-NC';
|
||||
case 'cc-by-nc-sa':
|
||||
return 'CC BY-NC-SA';
|
||||
case 'cc-by-nd':
|
||||
return 'CC BY-ND';
|
||||
case 'cc-by-nc-nd':
|
||||
return 'CC BY-NC-ND';
|
||||
default:
|
||||
return msg('All rights reserved');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project API-v1 observation results (the results ARRAY, not the response
|
||||
* envelope). Malformed rows are skipped; only a non-array rejects a snapshot.
|
||||
*/
|
||||
export function normalizeInatObservations(
|
||||
payload,
|
||||
{ nowMs, windowMs = 2 * DAY_MS } = {},
|
||||
) {
|
||||
if (!Array.isArray(payload)) return null;
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
for (const raw of payload) {
|
||||
if (!raw || !Number.isSafeInteger(raw.id) || raw.id <= 0) continue;
|
||||
if (raw.geoprivacy === 'private' || raw.taxon_geoprivacy === 'private')
|
||||
continue;
|
||||
if (raw.quality_grade !== 'research' && raw.quality_grade !== 'needs_id')
|
||||
continue;
|
||||
const stableId = `inat:${raw.id}`;
|
||||
if (ids.has(stableId)) continue;
|
||||
let lon = raw.geojson?.coordinates?.[0];
|
||||
let lat = raw.geojson?.coordinates?.[1];
|
||||
if (!validPosition(lat, lon) && typeof raw.location === 'string') {
|
||||
const parts = raw.location.split(',');
|
||||
if (parts.length === 2) [lat, lon] = parts.map(numberOrNull);
|
||||
}
|
||||
if (!validPosition(lat, lon)) continue;
|
||||
const obscured =
|
||||
raw.obscured === true ||
|
||||
raw.geoprivacy === 'obscured' ||
|
||||
raw.taxon_geoprivacy === 'obscured';
|
||||
const cell = obscured ? obscuredCell(lat, lon) : null;
|
||||
const time = parseAcartiaTime(raw.time_observed_at);
|
||||
const observedOn =
|
||||
dateOnly(raw.observed_on) ||
|
||||
(time !== null ? new Date(time).toISOString().slice(0, 10) : null);
|
||||
// The v1 d1 parameter is a DATE filter, so its first day over-fetches the
|
||||
// exact rolling window. Filter before hiding an obscured record's time.
|
||||
if (
|
||||
Number.isFinite(nowMs) &&
|
||||
!withinObservationWindow(time, observedOn, nowMs, windowMs)
|
||||
)
|
||||
continue;
|
||||
const photo = Array.isArray(raw.photos) ? raw.photos[0] : null;
|
||||
const taxon = raw.taxon;
|
||||
const faves = numberOrNull(raw.faves_count);
|
||||
const agreements = numberOrNull(raw.num_identification_agreements);
|
||||
ids.add(stableId);
|
||||
rows.push({
|
||||
stableId,
|
||||
origin: 'inaturalist',
|
||||
lat: cell ? coarsen((cell.south + cell.north) / 2, 1) : lat,
|
||||
lon: cell ? coarsen((cell.west + cell.east) / 2, 1) : lon,
|
||||
obscured,
|
||||
cell,
|
||||
observedAt: obscured ? null : time,
|
||||
observedOn,
|
||||
quality: raw.quality_grade,
|
||||
commonName: cleanText(taxon?.preferred_common_name, 100),
|
||||
scientificName: cleanText(taxon?.name, 100),
|
||||
iconic: cleanText(taxon?.iconic_taxon_name, 40) || 'Unknown',
|
||||
threatened: taxon?.threatened === true,
|
||||
provider: null,
|
||||
link:
|
||||
inatObservationUri(raw.uri) ||
|
||||
`https://www.inaturalist.org/observations/${raw.id}`,
|
||||
photo: isOpenPhoto(photo)
|
||||
? {
|
||||
square: inatPhotoUrl(photo.url),
|
||||
licence: photo.license_code,
|
||||
attribution: cleanText(photo.attribution, 160),
|
||||
}
|
||||
: null,
|
||||
// These public aggregate counters are used only for ambient photo rank.
|
||||
favesCount: faves !== null && faves >= 0 ? Math.floor(faves) : 0,
|
||||
identificationAgreements:
|
||||
agreements !== null && agreements >= 0 ? Math.floor(agreements) : 0,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// 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. */
|
||||
export function normalizeAcartiaSightings(payload, nowMs) {
|
||||
if (!Array.isArray(payload)) return null;
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
for (const raw of payload) {
|
||||
const id =
|
||||
typeof raw?.entry_id === 'string'
|
||||
? cleanText(raw.entry_id, 120)
|
||||
: Number.isFinite(raw?.entry_id)
|
||||
? String(raw.entry_id)
|
||||
: null;
|
||||
if (!id) continue;
|
||||
const stableId = `acartia:${id}`;
|
||||
if (ids.has(stableId)) continue;
|
||||
const lat = numberOrNull(raw.latitude);
|
||||
const lon = numberOrNull(raw.longitude);
|
||||
const observedAt = parseAcartiaTime(raw.created);
|
||||
const name = cleanText(raw.data_source_name, 60);
|
||||
const species = cleanText(raw.type, 100);
|
||||
// Validate the original field: truncating a long, ambiguous name could
|
||||
// otherwise turn a prefix such as "Orca ... or seal" into a valid species.
|
||||
const speciesGroup = acartiaSpeciesGroup(raw.type);
|
||||
if (
|
||||
!validPosition(lat, lon) ||
|
||||
!inAcartiaCoverage(lat, lon) ||
|
||||
!name ||
|
||||
!speciesGroup ||
|
||||
observedAt === null ||
|
||||
!Number.isFinite(nowMs) ||
|
||||
nowMs - observedAt > ACARTIA_MAX_AGE_MS ||
|
||||
observedAt - nowMs > OBSERVATION_FUTURE_TOLERANCE_MS
|
||||
)
|
||||
continue;
|
||||
ids.add(stableId);
|
||||
rows.push({
|
||||
stableId,
|
||||
origin: 'acartia',
|
||||
lat: coarsen(lat),
|
||||
lon: coarsen(lon),
|
||||
obscured: false,
|
||||
cell: null,
|
||||
observedAt,
|
||||
observedOn: new Date(observedAt).toISOString().slice(0, 10),
|
||||
quality:
|
||||
raw.trusted === true || raw.trusted === 1 || raw.trusted === 'true'
|
||||
? 'research'
|
||||
: 'needs_id',
|
||||
commonName: species,
|
||||
scientificName: null,
|
||||
iconic: 'Mammalia',
|
||||
cetacean: speciesGroup === 'cetacean',
|
||||
threatened: false,
|
||||
provider: { name, entity: cleanText(raw.data_source_entity, 60) },
|
||||
link: ACARTIA_SITE,
|
||||
photo: null,
|
||||
favesCount: 0,
|
||||
identificationAgreements: 0,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Recheck observation age on projected snapshots, including stale cache rows. */
|
||||
export function mergeSightings(
|
||||
inatRows,
|
||||
acartiaRows,
|
||||
{ nowMs, taxa = 'all', confirmed = false } = {},
|
||||
) {
|
||||
const byId = new Map();
|
||||
for (const row of Array.isArray(inatRows) ? inatRows : []) {
|
||||
if (isRecentWildlifeSighting(row, nowMs, taxa)) byId.set(row.stableId, row);
|
||||
}
|
||||
for (const row of Array.isArray(acartiaRows) ? acartiaRows : []) {
|
||||
if (
|
||||
!inAcartiaCoverage(row.lat, row.lon) ||
|
||||
!isRecentWildlifeSighting(row, nowMs, taxa)
|
||||
)
|
||||
continue;
|
||||
if (confirmed && row.quality !== 'research') continue;
|
||||
if (/inaturalist/i.test(row.provider?.name || '')) continue;
|
||||
if (!['all', 'Mammalia', 'whales'].includes(taxa)) continue;
|
||||
if (taxa === 'whales' && row.cetacean !== true) continue;
|
||||
byId.set(row.stableId, row);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
normalizeInatObservations,
|
||||
normalizeAcartiaSightings,
|
||||
parseAcartiaTime,
|
||||
inatPhotoUrl,
|
||||
isOpenPhoto,
|
||||
obscuredCell,
|
||||
inatLicenceLabel,
|
||||
mergeSightings,
|
||||
isRecentWildlifeSighting,
|
||||
} from './records.js';
|
||||
|
||||
const now = Date.UTC(2026, 10, 4, 18);
|
||||
const hour = 3_600_000;
|
||||
const photo = {
|
||||
url: 'https://inaturalist-open-data.s3.amazonaws.com/photos/123/square.jpeg',
|
||||
license_code: 'cc-by-nc',
|
||||
attribution: '(c) Synthetic Photographer, some rights reserved',
|
||||
};
|
||||
const observation = (overrides = {}) => ({
|
||||
id: 123,
|
||||
uri: 'https://www.inaturalist.org/observations/123',
|
||||
geojson: { coordinates: [-122.351, 47.613] },
|
||||
positional_accuracy: 12,
|
||||
public_positional_accuracy: 12,
|
||||
obscured: false,
|
||||
geoprivacy: null,
|
||||
taxon_geoprivacy: null,
|
||||
time_observed_at: '2026-11-04T16:00:00Z',
|
||||
observed_on: '2026-11-04',
|
||||
created_at: '2026-11-04T17:00:00Z',
|
||||
quality_grade: 'research',
|
||||
license_code: 'cc-by-nc',
|
||||
faves_count: 6,
|
||||
num_identification_agreements: 3,
|
||||
taxon: {
|
||||
name: 'Calypte anna',
|
||||
preferred_common_name: "Anna's Hummingbird",
|
||||
iconic_taxon_name: 'Aves',
|
||||
threatened: false,
|
||||
},
|
||||
photos: [photo],
|
||||
user: { login: 'SHOULD_NEVER_ESCAPE' },
|
||||
...overrides,
|
||||
});
|
||||
const sighting = (overrides = {}) => ({
|
||||
entry_id: 'abc',
|
||||
latitude: 48.12345,
|
||||
longitude: -122.54321,
|
||||
created: '2026-11-04 16:00:00',
|
||||
type: 'Orca',
|
||||
no_sighted: '5',
|
||||
photo_url: 'https://provider.example/photo.jpg',
|
||||
data_source_name: 'Synthetic Network',
|
||||
data_source_entity: 'Whale Alert',
|
||||
trusted: true,
|
||||
data_source_comments: 'PRIVATE PHONE AND NAME',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('normalizers reject only non-array snapshots and dedupe validated ids', () => {
|
||||
for (const input of [null, {}, { results: [] }, '']) {
|
||||
assert.equal(normalizeInatObservations(input), null);
|
||||
assert.equal(normalizeAcartiaSightings(input, now), null);
|
||||
}
|
||||
assert.deepEqual(normalizeInatObservations([]), []);
|
||||
assert.equal(
|
||||
normalizeInatObservations([null, observation(), observation()]).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeAcartiaSightings([null, sighting(), sighting()], now).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('iNaturalist projection retains only the listed display fields and aggregate photo rank', () => {
|
||||
const [row] = normalizeInatObservations([observation()]);
|
||||
assert.equal(row.stableId, 'inat:123');
|
||||
assert.equal(row.favesCount, 6);
|
||||
assert.equal(row.identificationAgreements, 3);
|
||||
assert.equal(row.photo.attribution, photo.attribution);
|
||||
assert.equal(row.observedAt, now - 2 * hour);
|
||||
for (const key of [
|
||||
'user',
|
||||
'login',
|
||||
'public_positional_accuracy',
|
||||
'geojson',
|
||||
'created_at',
|
||||
])
|
||||
assert.equal(key in row, false);
|
||||
assert.doesNotMatch(JSON.stringify(row), /SHOULD_NEVER_ESCAPE/);
|
||||
});
|
||||
|
||||
test('observation links accept only canonical source pages and fall back to the id', () => {
|
||||
for (const uri of [
|
||||
undefined,
|
||||
'https://www.inaturalist.org/observations/123\n',
|
||||
'https://www.inaturalist.org/observations/123?redirect=elsewhere',
|
||||
'http://www.inaturalist.org/observations/123',
|
||||
'https://www.inaturalist.org.evil.example/observations/123',
|
||||
]) {
|
||||
const [row] = normalizeInatObservations([observation({ uri })]);
|
||||
assert.equal(
|
||||
row.link,
|
||||
observation().uri,
|
||||
'the existing detail card keeps its canonical fallback',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('wildlife package entries construct without acquiring data or a DOM', async () => {
|
||||
const api = await import('gods-eye-view/layers/wildlife-sightings');
|
||||
const sources =
|
||||
await import('gods-eye-view/layers/wildlife-sightings/source');
|
||||
const layer = api.createWildlifeSightingsLayer({
|
||||
source: sources.createWildlifeSightingsSource({
|
||||
fetchImpl() {
|
||||
assert.fail('construction must not acquire');
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.equal(layer.id, 'wildlife-sightings');
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('only allow-listed photo licences with square open-data hosts and attribution render', () => {
|
||||
for (const license_code of [
|
||||
'cc0',
|
||||
'cc-by',
|
||||
'cc-by-sa',
|
||||
'cc-by-nc',
|
||||
'cc-by-nc-sa',
|
||||
]) {
|
||||
assert.equal(isOpenPhoto({ ...photo, license_code }), true);
|
||||
assert.ok(
|
||||
normalizeInatObservations([
|
||||
observation({ photos: [{ ...photo, license_code }] }),
|
||||
])[0].photo,
|
||||
);
|
||||
}
|
||||
for (const badPhoto of [
|
||||
{ ...photo, license_code: null },
|
||||
{ ...photo, license_code: 'cc-by-nd' },
|
||||
{ ...photo, license_code: 'cc-by-nc-nd' },
|
||||
{ ...photo, license_code: 'all rights reserved' },
|
||||
{ ...photo, url: 'https://static.inaturalist.org/photos/123/square.jpg' },
|
||||
{ ...photo, url: `${photo.url}?size=square` },
|
||||
{ ...photo, url: photo.url.replace('https:', 'http:') },
|
||||
{ ...photo, attribution: '' },
|
||||
{ ...photo, url: photo.url.replace('/square.', '/medium.') },
|
||||
]) {
|
||||
assert.equal(isOpenPhoto(badPhoto), false);
|
||||
assert.equal(
|
||||
normalizeInatObservations([observation({ photos: [badPhoto] })])[0].photo,
|
||||
null,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
inatPhotoUrl(photo.url, 'medium'),
|
||||
'https://inaturalist-open-data.s3.amazonaws.com/photos/123/medium.jpeg',
|
||||
);
|
||||
for (const extension of ['jpg', 'jpeg', 'PNG']) {
|
||||
const square = photo.url.replace('square.jpeg', `square.${extension}`);
|
||||
assert.equal(
|
||||
inatPhotoUrl(square, 'medium'),
|
||||
square.replace('/square.', '/medium.'),
|
||||
);
|
||||
}
|
||||
for (const invalid of [
|
||||
null,
|
||||
new URL(photo.url),
|
||||
photo.url.replace('https:', 'http:'),
|
||||
photo.url.replace('amazonaws.com', 'amazonaws.com.evil.example'),
|
||||
photo.url.replace('https://', 'https://user:secret@'),
|
||||
photo.url.replace('https://', 'https://evil.example@'),
|
||||
photo.url.replace('/photos/', '/other/photos/'),
|
||||
photo.url.replace('/123/', '/abc/'),
|
||||
photo.url.replace('/square.', '/medium.'),
|
||||
photo.url.replace('/square.', '/large.'),
|
||||
photo.url.replace('.jpeg', '.webp'),
|
||||
`${photo.url}?size=large`,
|
||||
`${photo.url}#large`,
|
||||
`${photo.url}\n`,
|
||||
]) {
|
||||
assert.equal(inatPhotoUrl(invalid, 'medium'), null, String(invalid));
|
||||
}
|
||||
for (const invalidSize of [
|
||||
'original',
|
||||
'small',
|
||||
'large',
|
||||
'Medium',
|
||||
'../medium',
|
||||
'medium?query=1',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(inatPhotoUrl(photo.url, invalidSize), null);
|
||||
}
|
||||
assert.equal(inatLicenceLabel('cc-by-nc-sa'), 'CC BY-NC-SA');
|
||||
assert.equal(inatLicenceLabel(null), 'All rights reserved');
|
||||
});
|
||||
|
||||
test('photo attribution is trimmed, control-free and bounded', () => {
|
||||
const attribution = ` Photo\u0000\n${'A'.repeat(200)} `;
|
||||
const [row] = normalizeInatObservations([
|
||||
observation({ photos: [{ ...photo, attribution }] }),
|
||||
]);
|
||||
assert.equal(row.photo.attribution.length, 160);
|
||||
assert.match(row.photo.attribution, /^PhotoA/);
|
||||
assert.doesNotMatch(row.photo.attribution, /[\u0000-\u001f]/);
|
||||
});
|
||||
|
||||
test('obscured rows reveal only cell centres and date, without accuracy', () => {
|
||||
assert.deepEqual(obscuredCell(47.613, -122.351), {
|
||||
south: 47.6,
|
||||
north: 47.8,
|
||||
west: -122.4,
|
||||
east: -122.2,
|
||||
});
|
||||
for (const hiding of [
|
||||
{ obscured: true },
|
||||
{ geoprivacy: 'obscured' },
|
||||
{ taxon_geoprivacy: 'obscured' },
|
||||
]) {
|
||||
const [row] = normalizeInatObservations([observation(hiding)]);
|
||||
assert.equal(row.obscured, true);
|
||||
assert.equal(row.lat, 47.7);
|
||||
assert.equal(row.lon, -122.3);
|
||||
assert.equal('accuracyM' in row, false);
|
||||
assert.equal(row.observedAt, null);
|
||||
assert.equal(row.observedOn, '2026-11-04');
|
||||
assert.doesNotMatch(JSON.stringify(row), /47\.613|-122\.351|16:00/);
|
||||
}
|
||||
assert.deepEqual(obscuredCell(90, 180), {
|
||||
south: 89.8,
|
||||
north: 90,
|
||||
west: 179.8,
|
||||
east: 180,
|
||||
});
|
||||
});
|
||||
|
||||
test('private, casual, invalid coordinates and invalid ids are skipped', () => {
|
||||
const invalid = [
|
||||
observation({ geoprivacy: 'private' }),
|
||||
observation({ taxon_geoprivacy: 'private' }),
|
||||
observation({ quality_grade: 'casual' }),
|
||||
observation({ id: -1 }),
|
||||
observation({ id: 1.5 }),
|
||||
observation({ geojson: { coordinates: [181, 47] } }),
|
||||
observation({ geojson: null }),
|
||||
];
|
||||
assert.deepEqual(normalizeInatObservations(invalid), []);
|
||||
const [fallback] = normalizeInatObservations([
|
||||
observation({
|
||||
geojson: null,
|
||||
location: '47.613,-122.351',
|
||||
uri: 'https://bad.example',
|
||||
}),
|
||||
]);
|
||||
assert.equal(fallback.lat, 47.613);
|
||||
assert.equal(fallback.link, 'https://www.inaturalist.org/observations/123');
|
||||
assert.deepEqual(
|
||||
normalizeInatObservations([observation({ geojson: null, location: ',0' })]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('Acartia accepts only the two stated UTC timestamp formats', () => {
|
||||
assert.equal(parseAcartiaTime('2026-11-04 16:00:00'), now - 2 * hour);
|
||||
assert.equal(parseAcartiaTime('2026-11-04T16:00:00Z'), now - 2 * hour);
|
||||
assert.equal(parseAcartiaTime('2026-11-04T18:00:00+02:00'), now - 2 * hour);
|
||||
for (const value of [
|
||||
'2026-11-04T16:00:00',
|
||||
'11/04/2026',
|
||||
'2026-02-30 12:00:00',
|
||||
'2026-11-04 25:00:00',
|
||||
123,
|
||||
null,
|
||||
])
|
||||
assert.equal(parseAcartiaTime(value), null);
|
||||
});
|
||||
|
||||
test('Acartia coarsens before returning data, keeps provider, and never reads comments', () => {
|
||||
const raw = sighting();
|
||||
Object.defineProperty(raw, 'data_source_comments', {
|
||||
get() {
|
||||
assert.fail('comments read');
|
||||
},
|
||||
});
|
||||
const [row] = normalizeAcartiaSightings([raw], now);
|
||||
assert.equal(row.lat, 48.12);
|
||||
assert.equal(row.lon, -122.54);
|
||||
assert.deepEqual(row.provider, {
|
||||
name: 'Synthetic Network',
|
||||
entity: 'Whale Alert',
|
||||
});
|
||||
assert.equal(row.quality, 'research');
|
||||
assert.equal(row.photo, null);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(row),
|
||||
/data_source_comments|PRIVATE|48\.12345|-122\.54321/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Acartia requires flat provider text, handles trust, and sanitizes type', () => {
|
||||
for (const provider of ['', null, {}, { name: 'Invented nested shape' }]) {
|
||||
assert.deepEqual(
|
||||
normalizeAcartiaSightings(
|
||||
[sighting({ data_source_name: provider })],
|
||||
now,
|
||||
),
|
||||
[],
|
||||
);
|
||||
}
|
||||
for (const trusted of [true, 1, 'true'])
|
||||
assert.equal(
|
||||
normalizeAcartiaSightings([sighting({ trusted })], now)[0].quality,
|
||||
'research',
|
||||
);
|
||||
for (const trusted of [false, 0, 'false', '1'])
|
||||
assert.equal(
|
||||
normalizeAcartiaSightings([sighting({ trusted })], now)[0].quality,
|
||||
'needs_id',
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeAcartiaSightings([sighting({ type: ' \u0000 ' })], now),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
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() },
|
||||
{ created: 'not a timestamp' },
|
||||
{ latitude: 91 },
|
||||
{ longitude: -181 },
|
||||
{ entry_id: '' },
|
||||
])
|
||||
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:[email protected]/p.jpg',
|
||||
'javascript:bad()',
|
||||
'https://' + 'x'.repeat(501),
|
||||
null,
|
||||
]) {
|
||||
assert.equal(
|
||||
normalizeAcartiaSightings([sighting({ photo_url })], now)[0].link,
|
||||
'https://acartia.io/',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('merge applies Acartia windows, trust, preset and iNat dedupe', () => {
|
||||
const inat = normalizeInatObservations([observation()]);
|
||||
const rows = normalizeAcartiaSightings(
|
||||
[
|
||||
sighting(),
|
||||
sighting({
|
||||
entry_id: 'old',
|
||||
created: new Date(now - 3 * 24 * hour).toISOString(),
|
||||
}),
|
||||
sighting({ entry_id: 'untrusted', trusted: false }),
|
||||
sighting({
|
||||
entry_id: 'inat',
|
||||
data_source_name: 'iNaturalist observations',
|
||||
}),
|
||||
],
|
||||
now,
|
||||
);
|
||||
const ids = (options) =>
|
||||
mergeSightings(inat, rows, { nowMs: now, ...options }).map(
|
||||
(row) => row.stableId,
|
||||
);
|
||||
assert.deepEqual(ids({}), ['inat:123', 'acartia:abc', 'acartia:untrusted']);
|
||||
assert.deepEqual(ids({ confirmed: true }), ['inat:123', 'acartia:abc']);
|
||||
assert.ok(ids({ taxa: 'whales' }).includes('acartia:old'));
|
||||
assert.deepEqual(ids({ taxa: 'Aves' }), ['inat:123']);
|
||||
});
|
||||
|
||||
test('iNaturalist recency uses observation time and the exact preset window', () => {
|
||||
const raw = [
|
||||
observation({
|
||||
id: 1,
|
||||
time_observed_at: '2010-11-04T16:00:00Z',
|
||||
observed_on: '2010-11-04',
|
||||
created_at: new Date(now).toISOString(),
|
||||
}),
|
||||
observation({
|
||||
id: 2,
|
||||
time_observed_at: new Date(now - 48 * hour - 1).toISOString(),
|
||||
observed_on: '2026-11-02',
|
||||
}),
|
||||
observation({
|
||||
id: 3,
|
||||
time_observed_at: new Date(now - 48 * hour).toISOString(),
|
||||
observed_on: '2026-11-02',
|
||||
created_at: '2026-10-01T00:00:00Z',
|
||||
}),
|
||||
observation({
|
||||
id: 4,
|
||||
time_observed_at: new Date(now - 3 * 24 * hour).toISOString(),
|
||||
observed_on: '2026-11-01',
|
||||
}),
|
||||
];
|
||||
const ids = (rows) => rows.map((row) => row.stableId);
|
||||
assert.deepEqual(ids(normalizeInatObservations(raw, { nowMs: now })), [
|
||||
'inat:3',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
ids(
|
||||
normalizeInatObservations(raw, { nowMs: now, windowMs: 7 * 24 * hour }),
|
||||
),
|
||||
['inat:2', 'inat:3', 'inat:4'],
|
||||
);
|
||||
// Recheck cached/projected rows as the clock moves between server refreshes.
|
||||
assert.deepEqual(
|
||||
ids(mergeSightings(normalizeInatObservations(raw), [], { nowMs: now })),
|
||||
['inat:3'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
mergeSightings(normalizeInatObservations([raw[2]]), [], { nowMs: now + 1 }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('date-only and obscured iNaturalist recency never includes a partly expired date', () => {
|
||||
const rows = normalizeInatObservations(
|
||||
[
|
||||
observation({ id: 1, time_observed_at: null, observed_on: '2026-11-02' }),
|
||||
observation({ id: 2, time_observed_at: null, observed_on: '2026-11-03' }),
|
||||
observation({ id: 3, time_observed_at: null, observed_on: null }),
|
||||
observation({
|
||||
id: 4,
|
||||
obscured: true,
|
||||
time_observed_at: '2010-11-04T16:00:00Z',
|
||||
observed_on: '2010-11-04',
|
||||
}),
|
||||
],
|
||||
{ nowMs: now },
|
||||
);
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.stableId),
|
||||
['inat:2'],
|
||||
);
|
||||
});
|
||||
|
||||
test('retained sightings use the same exact, date-only and preset recency limits', () => {
|
||||
const boundary = { observedAt: now - 48 * hour };
|
||||
assert.equal(isRecentWildlifeSighting(boundary, now), true);
|
||||
assert.equal(isRecentWildlifeSighting(boundary, now + 1), false);
|
||||
assert.equal(isRecentWildlifeSighting(boundary, now + 1, 'whales'), true);
|
||||
const whaleBoundary = { observedAt: now - 7 * 24 * hour };
|
||||
assert.equal(isRecentWildlifeSighting(whaleBoundary, now, 'whales'), true);
|
||||
assert.equal(
|
||||
isRecentWildlifeSighting(whaleBoundary, now + 1, 'whales'),
|
||||
false,
|
||||
);
|
||||
assert.equal(isRecentWildlifeSighting(whaleBoundary, now, 'Mammalia'), false);
|
||||
for (const [observedOn, expected] of [
|
||||
['2026-11-02', false],
|
||||
['2026-11-03', true],
|
||||
['2026-11-04', true],
|
||||
]) {
|
||||
assert.equal(
|
||||
isRecentWildlifeSighting({ observedAt: null, observedOn }, now),
|
||||
expected,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
isRecentWildlifeSighting({ observedOn: '2026-11-03' }, now + 24 * hour),
|
||||
false,
|
||||
);
|
||||
for (const row of [
|
||||
null,
|
||||
{},
|
||||
{ observedOn: '2010-11-04' },
|
||||
{ observedOn: 'invalid' },
|
||||
]) {
|
||||
assert.equal(isRecentWildlifeSighting(row, now), false);
|
||||
}
|
||||
assert.equal(isRecentWildlifeSighting(boundary, NaN), false);
|
||||
});
|
||||
|
||||
test('Acartia validates the documented type field before applying the WHALES preset', () => {
|
||||
const rows = normalizeAcartiaSightings(
|
||||
[
|
||||
sighting({ entry_id: 'orca', type: 'Orca' }),
|
||||
sighting({ entry_id: 'scientific', type: 'Phocoena phocoena' }),
|
||||
sighting({ entry_id: 'seal', type: 'Harbor seal' }),
|
||||
sighting({
|
||||
entry_id: 'unidentified',
|
||||
type: 'Unidentified marine mammal',
|
||||
}),
|
||||
sighting({ entry_id: 'unknown-whale', type: 'Unidentified whale' }),
|
||||
sighting({ entry_id: 'unknown', type: 'Unknown' }),
|
||||
sighting({ entry_id: 'empty', type: '' }),
|
||||
sighting({ entry_id: 'fish', type: 'Whale shark' }),
|
||||
sighting({ entry_id: 'text', type: 'Maybe an Orca or a seal' }),
|
||||
sighting({ entry_id: 'prefix', type: `Orca${' '.repeat(120)}or a seal` }),
|
||||
sighting({ entry_id: 'invented-shape', type: { species: 'Orca' } }),
|
||||
],
|
||||
now,
|
||||
);
|
||||
const ids = (taxa) =>
|
||||
mergeSightings([], rows, { nowMs: now, taxa }).map((row) => row.stableId);
|
||||
assert.deepEqual(ids('whales'), ['acartia:orca', 'acartia:scientific']);
|
||||
const mammals = [
|
||||
'acartia:orca',
|
||||
'acartia:scientific',
|
||||
'acartia:seal',
|
||||
'acartia:unidentified',
|
||||
'acartia:unknown-whale',
|
||||
];
|
||||
assert.deepEqual(ids('all'), mammals);
|
||||
assert.deepEqual(ids('Mammalia'), mammals);
|
||||
assert.deepEqual(ids('Aves'), []);
|
||||
});
|
||||
|
||||
test('Acartia rejects out-of-coverage rows before rounding or caching', () => {
|
||||
for (const position of [
|
||||
{ latitude: 45.49999 },
|
||||
{ latitude: 50.50001 },
|
||||
{ longitude: -126.00001 },
|
||||
{ longitude: -120.99999 },
|
||||
{ latitude: 37.8, longitude: -122.4 },
|
||||
]) {
|
||||
assert.deepEqual(normalizeAcartiaSightings([sighting(position)], now), []);
|
||||
}
|
||||
for (const position of [
|
||||
{ latitude: 45.5, longitude: -126 },
|
||||
{ latitude: 50.5, longitude: -121 },
|
||||
]) {
|
||||
assert.equal(
|
||||
normalizeAcartiaSightings([sighting(position)], now).length,
|
||||
1,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { performance, PerformanceObserver } from 'node:perf_hooks';
|
||||
import test from 'node:test';
|
||||
import { perfBudgetTestOptions } from '../../../scripts/run-perf-tests.mjs';
|
||||
import { denseContinentalRendering } from './renderPerformanceFixtures.mjs';
|
||||
import { WILDLIFE_MOVE_END_BUDGET_MS } from './performance.js';
|
||||
|
||||
const median = (values) =>
|
||||
[...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];
|
||||
const percentile95 = (values) =>
|
||||
[...values].sort((a, b) => a - b)[Math.floor(values.length * 0.95)];
|
||||
|
||||
// A GC pause is outside the layer's CPU work. Keep raw wall times in the
|
||||
// diagnostic and subtract only directly observed, overlapping GC intervals
|
||||
// for the strict CPU budget; never hide a slow layer phase as an outlier.
|
||||
function activeMs(slice, collections) {
|
||||
const start = slice.startTimeMs;
|
||||
const end = start + slice.durationMs;
|
||||
return (
|
||||
slice.durationMs -
|
||||
collections.reduce(
|
||||
(sum, gc) =>
|
||||
sum +
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(end, gc.startTime + gc.duration) -
|
||||
Math.max(start, gc.startTime),
|
||||
),
|
||||
0,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
'dense continental production moveEnd pipeline yields every 4ms, including cold tiles and real Cesium billboard updates',
|
||||
perfBudgetTestOptions(),
|
||||
async (t) => {
|
||||
// Settle CPU-triggered runtime startup before creating the cold layer;
|
||||
// cells, coordinates and billboard collections remain unwarmed.
|
||||
const calibrationStart = performance.now();
|
||||
let calibrationChecksum = 0;
|
||||
for (;;) {
|
||||
const cpu = process.threadCpuUsage();
|
||||
if (cpu.user + cpu.system >= 1_100_000) break;
|
||||
for (let i = 0; i < 65536; i++)
|
||||
calibrationChecksum =
|
||||
(calibrationChecksum + i) ^ (calibrationChecksum >>> 16);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
const runtimeSettleMs = performance.now() - calibrationStart;
|
||||
const collections = [];
|
||||
const phases = [];
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries())
|
||||
(entry.entryType === 'gc' ? collections : phases).push(entry);
|
||||
});
|
||||
observer.observe({ entryTypes: ['gc', 'measure'] });
|
||||
const h = denseContinentalRendering();
|
||||
const runs = [];
|
||||
try {
|
||||
for (let i = 0; i < 65; i++) {
|
||||
const start = performance.now();
|
||||
assert.equal(h.moveEnd((i % 4) * 3), 'pending');
|
||||
const enqueueMs = performance.now() - start;
|
||||
const result = h.flush();
|
||||
runs.push({
|
||||
startTimeMs: start,
|
||||
endTimeMs: performance.now(),
|
||||
enqueueMs,
|
||||
...result,
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
for (const entry of observer.takeRecords())
|
||||
(entry.entryType === 'gc' ? collections : phases).push(entry);
|
||||
const warm = runs.slice(25);
|
||||
const allSlices = runs.flatMap((run) => run.slices);
|
||||
const warmSlices = warm.flatMap((run) => run.slices);
|
||||
const activeSlices = allSlices.map((slice) =>
|
||||
activeMs(slice, collections),
|
||||
);
|
||||
const warmTotal = warm.map((run) =>
|
||||
run.slices.reduce(
|
||||
(sum, slice) => sum + activeMs(slice, collections),
|
||||
run.enqueueMs,
|
||||
),
|
||||
);
|
||||
const enqueueMax = Math.max(...runs.map((run) => run.enqueueMs));
|
||||
const coldMax = Math.max(
|
||||
...runs[0].slices.map((slice) => activeMs(slice, collections)),
|
||||
);
|
||||
const sliceMax = Math.max(...activeSlices);
|
||||
if (sliceMax > WILDLIFE_MOVE_END_BUDGET_MS) {
|
||||
const slow = allSlices[activeSlices.indexOf(sliceMax)];
|
||||
const entries = phases
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.entryType === 'measure' &&
|
||||
entry.name !== 'wildlife-sightings:moveEnd:slice' &&
|
||||
entry.startTime >= slow.startTimeMs &&
|
||||
entry.startTime < slow.startTimeMs + slow.durationMs,
|
||||
)
|
||||
.map((entry) => ({
|
||||
phase: entry.name,
|
||||
durationMs: entry.duration,
|
||||
activeMs: activeMs(
|
||||
{ startTimeMs: entry.startTime, durationMs: entry.duration },
|
||||
collections,
|
||||
),
|
||||
}));
|
||||
t.diagnostic(
|
||||
`Slow slice ${JSON.stringify({ slice: slow, phases: entries, gc: collections.map((entry) => ({ start: entry.startTime, duration: entry.duration })), now: performance.now(), uptimeMs: process.uptime() * 1000 })}`,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
enqueueMax <= WILDLIFE_MOVE_END_BUDGET_MS,
|
||||
`moveEnd enqueue ${enqueueMax.toFixed(3)}ms`,
|
||||
);
|
||||
assert.ok(
|
||||
sliceMax <= WILDLIFE_MOVE_END_BUDGET_MS,
|
||||
`longest layer CPU slice ${sliceMax.toFixed(3)}ms exceeds 4ms`,
|
||||
);
|
||||
assert.ok(
|
||||
median(warmTotal) <= WILDLIFE_MOVE_END_BUDGET_MS,
|
||||
`median full moveEnd CPU ${median(warmTotal).toFixed(3)}ms exceeds 4ms`,
|
||||
);
|
||||
assert.equal(warm.at(-1).cartesianBuilds, 0);
|
||||
const phaseMeans = Object.fromEntries(
|
||||
[
|
||||
'density',
|
||||
'bucket',
|
||||
'rank',
|
||||
'billboard-update',
|
||||
'overlay-publish',
|
||||
].map((name) => {
|
||||
const duration = phases
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.name === `wildlife-sightings:${name}` &&
|
||||
entry.startTime >= warm[0].startTimeMs,
|
||||
)
|
||||
.reduce(
|
||||
(sum, entry) =>
|
||||
sum +
|
||||
activeMs(
|
||||
{
|
||||
startTimeMs: entry.startTime,
|
||||
durationMs: entry.duration,
|
||||
},
|
||||
collections,
|
||||
),
|
||||
0,
|
||||
);
|
||||
return [name, (duration / warm.length).toFixed(3)];
|
||||
}),
|
||||
);
|
||||
t.diagnostic(
|
||||
`6,144 cells + 500 observations, ${runs.at(-1).glowPrimitives} glow splats + ${runs.at(-1).billboards} real icons; moveEnd enqueue max=${enqueueMax.toFixed(3)}ms; cold max CPU slice=${coldMax.toFixed(3)}ms; all max CPU slice=${sliceMax.toFixed(3)}ms; warm total CPU median=${median(warmTotal).toFixed(3)}ms; warm slice p95=${percentile95(warmSlices.map((slice) => activeMs(slice, collections))).toFixed(3)}ms; raw wall slice max=${Math.max(...allSlices.map((slice) => slice.durationMs)).toFixed(3)}ms; observed GC pauses=${collections.length}; repeated Cartesian builds=0; CPU startup calibration=${runtimeSettleMs.toFixed(0)}ms, checksum=${calibrationChecksum} (cold fixture created afterward); warm phase mean ms=${JSON.stringify(phaseMeans)}`,
|
||||
);
|
||||
} finally {
|
||||
h.destroy();
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createSelection } from './selection.js';
|
||||
|
||||
export const DENSE_CONTINENT_CELLS = 6 * 32 * 32;
|
||||
export const DENSE_CONTINENT_ROWS = 500;
|
||||
const NOW = Date.parse('2026-11-04T18:00:00Z');
|
||||
|
||||
/** The real render + selection pipeline with Cesium coordinates/billboards.
|
||||
* Only the WebGL scene and external overlay host are replaced. Six fully
|
||||
* occupied logical tiles deliberately include off-window cells and collisions. */
|
||||
export function denseContinentalRendering({ onOperations } = {}) {
|
||||
const cells = [];
|
||||
const z = 3;
|
||||
for (let x = 1; x < 3; x++)
|
||||
for (let y = 1; y < 4; y++)
|
||||
for (let row = 1; row < 64; row += 2)
|
||||
for (let column = 1; column < 64; column += 2)
|
||||
cells.push({
|
||||
id: `grid:${z}/${x}/${y}:${row}:${column}`,
|
||||
lat:
|
||||
(Math.atan(
|
||||
Math.sinh(Math.PI * (1 - (2 * (y + row / 64)) / 2 ** z)),
|
||||
) *
|
||||
180) /
|
||||
Math.PI,
|
||||
lon: ((x + column / 64) / 2 ** z) * 360 - 180,
|
||||
cellCount: row * 64 + column,
|
||||
});
|
||||
const rows = cells
|
||||
.slice(1200, 1200 + DENSE_CONTINENT_ROWS)
|
||||
.map((cell, i) => ({
|
||||
stableId: `synthetic:continent:${i}`,
|
||||
origin: 'inaturalist',
|
||||
lat: cell.lat,
|
||||
lon: cell.lon,
|
||||
iconic: 'Aves',
|
||||
commonName: i % 2 ? `Synthetic bird ${i}` : null,
|
||||
observedAt: NOW - i * 1000,
|
||||
quality: 'research',
|
||||
obscured: false,
|
||||
cell: null,
|
||||
}));
|
||||
const primitives = {
|
||||
add: (value) => value,
|
||||
remove(value) {
|
||||
value.destroy();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const scene = {
|
||||
canvas: { clientWidth: 1440, clientHeight: 900 },
|
||||
primitives,
|
||||
};
|
||||
const state = {
|
||||
enabled: true,
|
||||
viewer: {
|
||||
scene,
|
||||
camera: { positionWC: {}, positionCartographic: { height: 2_000_000 } },
|
||||
},
|
||||
rows: new Map(rows.map((row) => [row.stableId, row])),
|
||||
gridCells: cells,
|
||||
gridAvailable: true,
|
||||
renderedRows: [],
|
||||
rowById: new Map(),
|
||||
pointById: new Map(),
|
||||
positions: new Map(),
|
||||
cells: [],
|
||||
staging: null,
|
||||
taxa: 'all',
|
||||
selectedId: null,
|
||||
query: { visibleBoxes: [{ south: 5, west: -130, north: 75, east: -50 }] },
|
||||
band: 'regional',
|
||||
ambientRows: [],
|
||||
ambientIds: [],
|
||||
labelRows: [],
|
||||
carriers: new Map(),
|
||||
contextIds: new Set(),
|
||||
};
|
||||
const centre = Cesium.Cartesian3.fromDegrees(-90, 42);
|
||||
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(centre);
|
||||
const east = Cesium.Matrix4.getColumn(frame, 0, new Cesium.Cartesian4());
|
||||
const north = Cesium.Matrix4.getColumn(frame, 1, new Cesium.Cartesian4());
|
||||
const metrics = { projections: 0, publications: 0, cartesianBuilds: 0 };
|
||||
let pan = 0;
|
||||
let nextIdle = 0;
|
||||
const idle = new Map();
|
||||
const slices = [];
|
||||
const timers = new Map();
|
||||
let nextTimer = 0;
|
||||
const parts = {};
|
||||
const services = {
|
||||
now: () => NOW,
|
||||
setTimer(fn, delay) {
|
||||
timers.set(++nextTimer, { fn, delay });
|
||||
return nextTimer;
|
||||
},
|
||||
clearTimer: (id) => timers.delete(id),
|
||||
requestIdleCallback(fn) {
|
||||
idle.set(++nextIdle, fn);
|
||||
return nextIdle;
|
||||
},
|
||||
cancelIdleCallback(id) {
|
||||
idle.delete(id);
|
||||
},
|
||||
performanceSlice(slice) {
|
||||
slices.push(slice);
|
||||
},
|
||||
cesium: {
|
||||
...Cesium,
|
||||
Cartesian3: class extends Cesium.Cartesian3 {
|
||||
static fromDegrees(...args) {
|
||||
metrics.cartesianBuilds++;
|
||||
return Cesium.Cartesian3.fromDegrees(...args);
|
||||
}
|
||||
},
|
||||
},
|
||||
project(_scene, anchor) {
|
||||
metrics.projections++;
|
||||
return {
|
||||
x:
|
||||
720 +
|
||||
(anchor.x * east.x + anchor.y * east.y + anchor.z * east.z) / 8000 +
|
||||
pan,
|
||||
y:
|
||||
450 -
|
||||
(anchor.x * north.x + anchor.y * north.y + anchor.z * north.z) / 8000,
|
||||
};
|
||||
},
|
||||
occluderFactory: () => ({ isPointVisible: () => true }),
|
||||
badgeSprites: {
|
||||
get: (group) => `data:image/svg+xml,${group}`,
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
render: { governorRequestRender() {} },
|
||||
overlayHost: {
|
||||
setEntries() {
|
||||
metrics.publications++;
|
||||
},
|
||||
},
|
||||
performanceOperations: onOperations,
|
||||
};
|
||||
parts.rendering = createRendering({ state, services, parts });
|
||||
parts.photos = { get: () => null, setAmbient() {} };
|
||||
parts.selection = createSelection({ state, services, parts });
|
||||
parts.rendering.init();
|
||||
return {
|
||||
state,
|
||||
metrics,
|
||||
moveEnd(offset = 0) {
|
||||
pan = offset;
|
||||
metrics.projections = metrics.publications = metrics.cartesianBuilds = 0;
|
||||
slices.length = 0;
|
||||
const result = parts.rendering.rebuild();
|
||||
if (result !== 'pending') {
|
||||
parts.selection.refreshAmbient();
|
||||
parts.selection.publishSelected();
|
||||
parts.selection.refreshContexts();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
flush(maxSlices = Infinity) {
|
||||
let count = 0;
|
||||
while (idle.size && count++ < maxSlices) {
|
||||
const [id, fn] = idle.entries().next().value;
|
||||
idle.delete(id);
|
||||
const threadStart = process.threadCpuUsage();
|
||||
fn({ timeRemaining: () => 8, didTimeout: false });
|
||||
const threadUsed = process.threadCpuUsage(threadStart);
|
||||
if (slices.length)
|
||||
slices.at(-1).threadCpuMs =
|
||||
(threadUsed.user + threadUsed.system) / 1000;
|
||||
}
|
||||
return {
|
||||
...metrics,
|
||||
billboards: state.renderedRows.length,
|
||||
glowPrimitives: parts.rendering.density?.size ?? 0,
|
||||
slices: [...slices],
|
||||
};
|
||||
},
|
||||
destroy() {
|
||||
parts.rendering.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
import { wildlifeAccent } from './cards.js';
|
||||
import {
|
||||
wildlifeIconModelWork,
|
||||
wildlifeIconRelevance,
|
||||
compareWildlifeIds,
|
||||
} from './model.js';
|
||||
import { createWildlifeBillboardModel } from './billboardModel.js';
|
||||
import { createWildlifeDensity } from './density.js';
|
||||
import { wildlifeSpriteCache } from './sprites.js';
|
||||
import { isRecentWildlifeSighting } from './records.js';
|
||||
import {
|
||||
drainWildlifeWork,
|
||||
createWildlifeWorkQueue,
|
||||
WILDLIFE_SYNC_CELL_LIMIT,
|
||||
WILDLIFE_WORK_UNIT,
|
||||
} from './performance.js';
|
||||
import {
|
||||
WILDLIFE_SCALE_BY_DISTANCE,
|
||||
WILDLIFE_BADGE_MAX_PX,
|
||||
WILDLIFE_GROUND_READY_MAX_CHECKS,
|
||||
WILDLIFE_SPRITE_PX,
|
||||
} from './policy.js';
|
||||
|
||||
/** Own sighting billboards, density glow and batched privacy cells. */
|
||||
export function createRendering({ state, services, parts }) {
|
||||
const C = services.cesium;
|
||||
const badgeSprites = services.badgeSprites ?? wildlifeSpriteCache();
|
||||
let batchDepth = 0;
|
||||
let pendingRenderReason = null;
|
||||
let billboards = null;
|
||||
let density = null;
|
||||
let cellsSignature = null;
|
||||
let ambientAnchorIds = new Set();
|
||||
const badgeScreens = new Map();
|
||||
const crowdedBadgeIds = new Set();
|
||||
const photoCardIds = new Set();
|
||||
const appearances = new Map();
|
||||
const surfaceAnchors = new WeakMap();
|
||||
let operations = null;
|
||||
const workQueue = createWildlifeWorkQueue({
|
||||
requestIdle: services.requestIdleCallback,
|
||||
cancelIdle: services.cancelIdleCallback,
|
||||
onSlice: (slice) => services.performanceSlice?.(slice),
|
||||
});
|
||||
|
||||
function surfaceAnchor(row) {
|
||||
const old = surfaceAnchors.get(row);
|
||||
if (old && old.lat === row.lat && old.lon === row.lon) return old.anchor;
|
||||
const anchor = C.Cartesian3.fromDegrees(row.lon, row.lat);
|
||||
surfaceAnchors.set(row, { lat: row.lat, lon: row.lon, anchor });
|
||||
if (operations) operations.positionBuilds++;
|
||||
return anchor;
|
||||
}
|
||||
const requestRender = (reason) => {
|
||||
if (batchDepth) {
|
||||
pendingRenderReason = reason;
|
||||
return;
|
||||
}
|
||||
services.render?.governorRequestRender?.(`wildlife-sightings-${reason}`);
|
||||
};
|
||||
function beginBatch() {
|
||||
batchDepth += 1;
|
||||
}
|
||||
function endBatch() {
|
||||
batchDepth = Math.max(0, batchDepth - 1);
|
||||
if (batchDepth || !pendingRenderReason) return;
|
||||
const reason = pendingRenderReason;
|
||||
pendingRenderReason = null;
|
||||
requestRender(reason);
|
||||
}
|
||||
const surfaceRemovers = [];
|
||||
const tilesetRemovers = new Map();
|
||||
|
||||
// Reuse the shared ground cache. One bounded resolve lifts cold anchors
|
||||
// without per-sighting scene renders and pixel readback.
|
||||
let floorResolve = null;
|
||||
let floorColdPending = false;
|
||||
function position(row) {
|
||||
if (row.origin === 'acartia') return surfaceAnchor(row);
|
||||
const floor = services.groundFloor?.cachedGroundFloor?.(row.lat, row.lon);
|
||||
if (floor == null && !floorColdPending) {
|
||||
// Anchors are built after the rows publish; warm once the pass ends.
|
||||
floorColdPending = true;
|
||||
queueMicrotask(warmColdFloors);
|
||||
}
|
||||
return Number.isFinite(floor) && floor !== 0
|
||||
? C.Cartesian3.fromDegrees(row.lon, row.lat, floor)
|
||||
: surfaceAnchor(row);
|
||||
}
|
||||
|
||||
function warmColdFloors() {
|
||||
if (!floorColdPending || floorResolve) return;
|
||||
floorColdPending = false;
|
||||
const resolve = services.groundFloor?.resolveGroundFloorCellsBounded;
|
||||
if (!state.enabled || typeof resolve !== 'function') return;
|
||||
const points = state.renderedRows
|
||||
.filter((row) => row.origin !== 'acartia')
|
||||
.map((row) => ({ lat: row.lat, lon: row.lon }));
|
||||
if (!points.length) return;
|
||||
floorResolve = Promise.resolve(resolve(points))
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
floorResolve = null;
|
||||
resampleAnchors();
|
||||
});
|
||||
}
|
||||
|
||||
function occluder() {
|
||||
const camera = state.viewer?.camera;
|
||||
return camera?.positionWC ? services.occluderFactory?.(camera) : null;
|
||||
}
|
||||
|
||||
function cull() {
|
||||
if (!state.enabled) return false;
|
||||
const horizon = occluder();
|
||||
let changed = false;
|
||||
// Only observation badges yield at adjacent bucket edges or to a painted
|
||||
// card. Density never participates in picking or icon spacing.
|
||||
for (const [id, point] of state.pointById) {
|
||||
const anchor = state.positions.get(id);
|
||||
const show =
|
||||
!photoCardIds.has(id) &&
|
||||
!crowdedBadgeIds.has(id) &&
|
||||
(!horizon || horizon.isPointVisible(anchor) === true);
|
||||
if (point.show === show) continue;
|
||||
point.show = show;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) requestRender('cull');
|
||||
return changed;
|
||||
}
|
||||
|
||||
function updateBadgeSpacing() {
|
||||
return drainWildlifeWork(updateBadgeSpacingWork());
|
||||
}
|
||||
|
||||
function* updateBadgeSpacingWork() {
|
||||
yield 'rank';
|
||||
crowdedBadgeIds.clear();
|
||||
const buckets = new Map();
|
||||
const rows = [...state.renderedRows].sort(
|
||||
(a, b) =>
|
||||
wildlifeIconRelevance(b, state.selectedId) -
|
||||
wildlifeIconRelevance(a, state.selectedId) ||
|
||||
compareWildlifeIds(a.stableId, b.stableId),
|
||||
);
|
||||
yield 'rank';
|
||||
let visits = 0;
|
||||
for (const row of rows) {
|
||||
if (visits++ % WILDLIFE_WORK_UNIT === 0) yield 'rank';
|
||||
if (photoCardIds.has(row.stableId)) continue;
|
||||
const screen = badgeScreens.get(row.stableId);
|
||||
if (!screen) continue;
|
||||
const x = Math.floor(screen.x / WILDLIFE_BADGE_MAX_PX);
|
||||
const y = Math.floor(screen.y / WILDLIFE_BADGE_MAX_PX);
|
||||
let overlaps = false;
|
||||
for (let dx = -1; dx <= 1 && !overlaps; dx++) {
|
||||
for (let dy = -1; dy <= 1 && !overlaps; dy++) {
|
||||
for (const other of buckets.get((x + dx) * 65536 + y + dy) || []) {
|
||||
if (operations) operations.spacingTests++;
|
||||
if (
|
||||
(screen.x - other.x) ** 2 + (screen.y - other.y) ** 2 <
|
||||
WILDLIFE_BADGE_MAX_PX ** 2
|
||||
) {
|
||||
overlaps = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (overlaps) crowdedBadgeIds.add(row.stableId);
|
||||
else {
|
||||
const key = x * 65536 + y;
|
||||
if (!buckets.has(key)) buckets.set(key, []);
|
||||
buckets.get(key).push(screen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called only when the host's painted membership changes, never per idle
|
||||
// frame. Requested/loading/rejected cards must leave their badges visible.
|
||||
function setPhotoCardVisibility(entryIds) {
|
||||
const next = new Set();
|
||||
for (const id of entryIds)
|
||||
if (id.startsWith('sighting:')) next.add(id.slice(9));
|
||||
const changed =
|
||||
next.size !== photoCardIds.size ||
|
||||
[...next].some((id) => !photoCardIds.has(id));
|
||||
photoCardIds.clear();
|
||||
for (const id of next) photoCardIds.add(id);
|
||||
if (!state.enabled) return;
|
||||
updateBadgeSpacing();
|
||||
cull();
|
||||
// A painted thumbnail already names its sighting; drop the repeat label.
|
||||
if (changed) parts.selection?.photoCardsChanged?.();
|
||||
}
|
||||
|
||||
function resampleAnchors() {
|
||||
if (!state.enabled || !state.points) return;
|
||||
let changed = false;
|
||||
for (const row of state.renderedRows) {
|
||||
if (row.origin === 'acartia') continue;
|
||||
const point = state.pointById.get(row.stableId);
|
||||
if (!point) continue;
|
||||
const anchor = position(row);
|
||||
if (C.Cartesian3.equals(anchor, state.positions.get(row.stableId)))
|
||||
continue;
|
||||
point.position = anchor;
|
||||
state.positions.set(row.stableId, anchor);
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return;
|
||||
beginBatch();
|
||||
try {
|
||||
cull();
|
||||
parts.selection.refreshAmbient();
|
||||
parts.selection.publishSelected();
|
||||
parts.selection.notify?.();
|
||||
parts.selection.refreshContexts();
|
||||
requestRender('terrain');
|
||||
} finally {
|
||||
endBatch();
|
||||
}
|
||||
}
|
||||
|
||||
function trackTileset(primitive) {
|
||||
if (
|
||||
tilesetRemovers.has(primitive) ||
|
||||
typeof primitive?.allTilesLoaded?.addEventListener !== 'function'
|
||||
)
|
||||
return false;
|
||||
tilesetRemovers.set(
|
||||
primitive,
|
||||
primitive.allTilesLoaded.addEventListener(resampleAnchors),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
workQueue.cancel();
|
||||
operations = null;
|
||||
for (const remove of surfaceRemovers.splice(0)) remove?.();
|
||||
for (const remove of tilesetRemovers.values()) remove?.();
|
||||
tilesetRemovers.clear();
|
||||
}
|
||||
|
||||
function activate() {
|
||||
deactivate();
|
||||
if (!state.enabled || !state.viewer) return;
|
||||
const scene = state.viewer.scene;
|
||||
const refreshBadgeImages = () => {
|
||||
if (!state.enabled) return;
|
||||
appearances.clear();
|
||||
if (billboards?.refreshImages((group) => badgeSprites.get(group)))
|
||||
requestRender('font-ready');
|
||||
};
|
||||
surfaceRemovers.push(badgeSprites.subscribe(refreshBadgeImages));
|
||||
refreshBadgeImages();
|
||||
const ownEvent = (event, listener) => {
|
||||
const remove = event?.addEventListener?.(listener);
|
||||
if (typeof remove === 'function') surfaceRemovers.push(remove);
|
||||
};
|
||||
ownEvent(scene.globe?.tileLoadProgressEvent, (pending) => {
|
||||
if (pending === 0) resampleAnchors();
|
||||
});
|
||||
// Scene forwards the globe event in Cesium; deduplicate that alias.
|
||||
for (const event of new Set([
|
||||
scene.terrainProviderChanged,
|
||||
scene.globe?.terrainProviderChanged,
|
||||
]))
|
||||
ownEvent(event, resampleAnchors);
|
||||
const primitives = scene.primitives;
|
||||
for (let i = 0; i < (primitives?.length || 0); i++)
|
||||
trackTileset(primitives.get(i));
|
||||
ownEvent(primitives?.primitiveAdded, (primitive) => {
|
||||
if (trackTileset(primitive)) resampleAnchors();
|
||||
});
|
||||
ownEvent(primitives?.primitiveRemoved, (primitive) => {
|
||||
if (!tilesetRemovers.has(primitive)) return;
|
||||
tilesetRemovers.get(primitive)?.();
|
||||
tilesetRemovers.delete(primitive);
|
||||
resampleAnchors();
|
||||
});
|
||||
services.eventTarget?.addEventListener?.(
|
||||
'gev:map-stack-changed',
|
||||
resampleAnchors,
|
||||
);
|
||||
surfaceRemovers.push(() =>
|
||||
services.eventTarget?.removeEventListener?.(
|
||||
'gev:map-stack-changed',
|
||||
resampleAnchors,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function removeCell(primitive) {
|
||||
const owners =
|
||||
state.viewer?.scene?.groundPrimitives || state.viewer?.scene?.primitives;
|
||||
if (owners?.remove?.(primitive) !== true && !primitive.isDestroyed?.())
|
||||
primitive.destroy?.();
|
||||
}
|
||||
|
||||
function cancelStaging() {
|
||||
const pending = state.staging;
|
||||
if (!pending) return;
|
||||
state.staging = null;
|
||||
pending.remove?.();
|
||||
for (const primitive of pending.primitives) removeCell(primitive);
|
||||
}
|
||||
|
||||
function clearCells() {
|
||||
cancelStaging();
|
||||
for (const primitive of state.cells) removeCell(primitive);
|
||||
state.cells = [];
|
||||
cellsSignature = null;
|
||||
}
|
||||
|
||||
function stageCells(rows) {
|
||||
const cellsVisible = state.band === 'cells' || state.band === 'cards';
|
||||
const unique = new Map();
|
||||
if (cellsVisible) {
|
||||
for (const row of rows) {
|
||||
if (!row.cell) continue;
|
||||
const cell = row.cell;
|
||||
const key = `${cell.south},${cell.west},${cell.north},${cell.east}:${wildlifeAccent(row.iconic)}`;
|
||||
if (!unique.has(key)) unique.set(key, row);
|
||||
}
|
||||
}
|
||||
const signature = JSON.stringify([...unique.keys()].sort());
|
||||
if (signature === cellsSignature) return false;
|
||||
cancelStaging();
|
||||
if (!unique.size) {
|
||||
clearCells();
|
||||
cellsSignature = signature;
|
||||
return true;
|
||||
}
|
||||
cellsSignature = signature;
|
||||
const fills = [];
|
||||
const outlines = [];
|
||||
for (const row of unique.values()) {
|
||||
const { south, west, north, east } = row.cell;
|
||||
const tint = C.Color.fromCssColorString(wildlifeAccent(row.iconic));
|
||||
fills.push(
|
||||
new C.GeometryInstance({
|
||||
geometry: new C.RectangleGeometry({
|
||||
rectangle: C.Rectangle.fromDegrees(west, south, east, north),
|
||||
vertexFormat: C.PerInstanceColorAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: {
|
||||
color: C.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint.withAlpha(0.25),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
outlines.push(
|
||||
new C.GeometryInstance({
|
||||
geometry: new C.GroundPolylineGeometry({
|
||||
positions: C.Cartesian3.fromDegreesArray([
|
||||
west,
|
||||
south,
|
||||
east,
|
||||
south,
|
||||
east,
|
||||
north,
|
||||
west,
|
||||
north,
|
||||
west,
|
||||
south,
|
||||
]),
|
||||
width: 2,
|
||||
}),
|
||||
attributes: {
|
||||
color: C.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint.withAlpha(0.8),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Hidden primitives still compile their geometry. Poll only these two
|
||||
// nonempty batches, then atomically retire the previous privacy cells.
|
||||
const primitives = [
|
||||
new C.GroundPrimitive({
|
||||
geometryInstances: fills,
|
||||
appearance: new C.PerInstanceColorAppearance({
|
||||
flat: true,
|
||||
translucent: true,
|
||||
}),
|
||||
classificationType: C.ClassificationType.BOTH,
|
||||
allowPicking: false,
|
||||
releaseGeometryInstances: false,
|
||||
show: false,
|
||||
}),
|
||||
new C.GroundPolylinePrimitive({
|
||||
geometryInstances: outlines,
|
||||
appearance: new C.PolylineColorAppearance(),
|
||||
classificationType: C.ClassificationType.BOTH,
|
||||
allowPicking: false,
|
||||
releaseGeometryInstances: false,
|
||||
show: false,
|
||||
}),
|
||||
];
|
||||
const owners =
|
||||
state.viewer.scene.groundPrimitives || state.viewer.scene.primitives;
|
||||
for (const primitive of primitives) owners.add(primitive);
|
||||
const pending = { primitives, remove: null, checks: 0 };
|
||||
state.staging = pending;
|
||||
const ready = () => {
|
||||
if (state.staging !== pending || !state.enabled) return;
|
||||
if (!primitives.every((primitive) => primitive.ready === true)) {
|
||||
pending.checks += 1;
|
||||
if (pending.checks >= WILDLIFE_GROUND_READY_MAX_CHECKS) {
|
||||
cancelStaging();
|
||||
cellsSignature = null;
|
||||
return;
|
||||
}
|
||||
requestRender('staging');
|
||||
return;
|
||||
}
|
||||
pending.remove?.();
|
||||
state.staging = null;
|
||||
for (const primitive of state.cells) removeCell(primitive);
|
||||
state.cells = primitives;
|
||||
for (const primitive of primitives) primitive.show = true;
|
||||
requestRender('render');
|
||||
};
|
||||
pending.remove = state.viewer.scene.postRender?.addEventListener?.(ready);
|
||||
ready();
|
||||
return true;
|
||||
}
|
||||
|
||||
function init() {
|
||||
density = createWildlifeDensity({
|
||||
cesium: C,
|
||||
viewer: state.viewer,
|
||||
positionForCell: surfaceAnchor,
|
||||
requestRender,
|
||||
setTimer: services.setTimer,
|
||||
clearTimer: services.clearTimer,
|
||||
requestIdle: services.requestIdleCallback,
|
||||
cancelIdle: services.cancelIdleCallback,
|
||||
eventTarget: services.eventTarget,
|
||||
});
|
||||
state.points = new C.BillboardCollection();
|
||||
state.points.show = false;
|
||||
state.viewer.scene.primitives.add(state.points);
|
||||
services.sprites?.registerSpriteCollection?.(
|
||||
'wildlife-sightings',
|
||||
state.points,
|
||||
);
|
||||
const distanceScale = new C.NearFarScalar(...WILDLIFE_SCALE_BY_DISTANCE);
|
||||
billboards = createWildlifeBillboardModel({
|
||||
collection: state.points,
|
||||
pointById: state.pointById,
|
||||
positions: state.positions,
|
||||
positionForRow: position,
|
||||
appearanceForGroup(group) {
|
||||
if (!appearances.has(group))
|
||||
appearances.set(group, {
|
||||
image: badgeSprites.get(group),
|
||||
width: WILDLIFE_SPRITE_PX,
|
||||
height: WILDLIFE_SPRITE_PX,
|
||||
// The sprite includes its tint and white glyph. Multiplication
|
||||
// by a taxon colour would tint the glyph as well.
|
||||
color: C.Color.WHITE,
|
||||
scale: 1,
|
||||
scaleByDistance: distanceScale,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
return appearances.get(group);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function rebuild() {
|
||||
if (!state.enabled || !state.points) return false;
|
||||
workQueue.cancel();
|
||||
operations = null;
|
||||
const work = rebuildWork();
|
||||
if (
|
||||
(state.gridCells?.length || 0) > WILDLIFE_SYNC_CELL_LIMIT ||
|
||||
state.rows.size > WILDLIFE_SYNC_CELL_LIMIT
|
||||
) {
|
||||
workQueue.start(work, () => {
|
||||
if (!state.enabled) return;
|
||||
parts.selection.refreshAmbient();
|
||||
parts.selection.publishSelected();
|
||||
parts.selection.refreshContexts();
|
||||
parts.selection.syncCredits?.();
|
||||
parts.selection.notify?.();
|
||||
parts.controls?.notify?.();
|
||||
});
|
||||
return 'pending';
|
||||
}
|
||||
return drainWildlifeWork(work);
|
||||
}
|
||||
|
||||
function* rebuildWork() {
|
||||
operations = {
|
||||
positionBuilds: 0,
|
||||
spacingTests: 0,
|
||||
billboardVisits: 0,
|
||||
billboardWrites: 0,
|
||||
};
|
||||
const nowMs = services.now();
|
||||
const taxa = state.drawnParams?.taxa ?? state.taxa;
|
||||
const scene = state.viewer.scene;
|
||||
const horizon = occluder();
|
||||
const densityResult = yield* density.updateWork(
|
||||
state.gridAvailable ? state.gridCells || [] : [],
|
||||
{
|
||||
boxes: state.query?.visibleBoxes ?? state.query?.boxes,
|
||||
isPointVisible: horizon
|
||||
? (anchor) => horizon.isPointVisible(anchor) === true
|
||||
: null,
|
||||
onOperations: (counts) => Object.assign(operations, counts),
|
||||
},
|
||||
);
|
||||
if (densityResult.changed)
|
||||
services.sprites?.restoreSpriteOrder?.(state.viewer);
|
||||
const model = yield* wildlifeIconModelWork({
|
||||
rows: [...state.rows.values()].filter(
|
||||
(row) =>
|
||||
row.stableId === state.selectedId ||
|
||||
isRecentWildlifeSighting(row, nowMs, taxa),
|
||||
),
|
||||
boxes: state.query?.visibleBoxes ?? state.query?.boxes,
|
||||
selectedId: state.selectedId,
|
||||
selectedRow: state.selectedRow,
|
||||
preferredIds: state.ambientIds,
|
||||
onOperations: (counts) => Object.assign(operations, counts),
|
||||
projectRow:
|
||||
typeof services.project === 'function'
|
||||
? (row) => {
|
||||
const anchor = surfaceAnchor(row);
|
||||
const cached = surfaceAnchors.get(row);
|
||||
cached.screen = null;
|
||||
if (horizon && horizon.isPointVisible(anchor) !== true)
|
||||
return null;
|
||||
const screen = services.project(scene, anchor);
|
||||
if (
|
||||
!screen ||
|
||||
screen.x < 0 ||
|
||||
screen.y < 0 ||
|
||||
screen.x >= (scene.canvas?.clientWidth ?? Infinity) ||
|
||||
screen.y >= (scene.canvas?.clientHeight ?? Infinity)
|
||||
)
|
||||
return null;
|
||||
cached.screen = screen;
|
||||
return screen;
|
||||
}
|
||||
: null,
|
||||
});
|
||||
yield 'rank';
|
||||
badgeScreens.clear();
|
||||
for (const row of model.rows) {
|
||||
const screen = surfaceAnchors.get(row)?.screen;
|
||||
if (screen) badgeScreens.set(row.stableId, screen);
|
||||
}
|
||||
state.renderPipelineOperations = operations;
|
||||
state.renderedRows = model.rows;
|
||||
yield* updateBadgeSpacingWork();
|
||||
yield 'rank';
|
||||
state.sightingCount = state.gridAvailable
|
||||
? Math.max(
|
||||
densityResult.sightingCount,
|
||||
model.sightingCount - model.acartiaCount,
|
||||
) + model.acartiaCount
|
||||
: model.sightingCount;
|
||||
const previousRows = state.rowById;
|
||||
state.rowById = new Map(
|
||||
state.renderedRows.map((row) => [row.stableId, row]),
|
||||
);
|
||||
// Panning can bring two visible photo incumbents into one icon bucket.
|
||||
// Keep the single billboard winner, but preserve both card identities and
|
||||
// anchors until selection's front-side/window/height checks release them.
|
||||
const ambientAnchors = new Map();
|
||||
for (const incumbent of state.ambientRows || []) {
|
||||
const row = state.rows.get(incumbent.stableId);
|
||||
if (!row || !isRecentWildlifeSighting(row, nowMs, taxa)) continue;
|
||||
if (state.rowById.has(row.stableId)) continue;
|
||||
const old = previousRows.get(row.stableId);
|
||||
const cached = state.positions.get(row.stableId);
|
||||
ambientAnchors.set(
|
||||
row.stableId,
|
||||
cached && old?.lat === row.lat && old?.lon === row.lon
|
||||
? cached
|
||||
: position(row),
|
||||
);
|
||||
state.rowById.set(row.stableId, row);
|
||||
}
|
||||
function restoreAmbientAnchors() {
|
||||
for (const id of ambientAnchorIds)
|
||||
if (!ambientAnchors.has(id) && !state.pointById.has(id))
|
||||
state.positions.delete(id);
|
||||
for (const [id, anchor] of ambientAnchors)
|
||||
state.positions.set(id, anchor);
|
||||
ambientAnchorIds = new Set(ambientAnchors.keys());
|
||||
}
|
||||
if (state.selectedId && !state.rowById.has(state.selectedId))
|
||||
parts.selection.clear();
|
||||
yield 'billboard-update';
|
||||
const billboardUpdate = yield* billboards.updateWork(state.renderedRows, {
|
||||
onOperations: (counts) => Object.assign(operations, counts),
|
||||
});
|
||||
yield 'overlay-publish';
|
||||
let changed = densityResult.changed;
|
||||
beginBatch();
|
||||
try {
|
||||
restoreAmbientAnchors();
|
||||
changed = stageCells(state.renderedRows) || changed;
|
||||
changed = cull() || changed;
|
||||
if (
|
||||
billboardUpdate.created ||
|
||||
billboardUpdate.removed ||
|
||||
billboardUpdate.writes
|
||||
)
|
||||
changed = true;
|
||||
if (changed) requestRender('render');
|
||||
} finally {
|
||||
endBatch();
|
||||
}
|
||||
services.performanceOperations?.(operations);
|
||||
operations = null;
|
||||
return changed;
|
||||
}
|
||||
|
||||
function refreshSelection() {
|
||||
if (!state.enabled || !billboards) return false;
|
||||
// Selection changes spacing priority without reprojecting the view.
|
||||
updateBadgeSpacing();
|
||||
return cull();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
workQueue.cancel();
|
||||
operations = null;
|
||||
clearCells();
|
||||
density?.clear();
|
||||
billboards?.clear();
|
||||
if (state.points) state.points.show = false;
|
||||
state.pointById.clear();
|
||||
state.positions.clear();
|
||||
state.renderedRows = [];
|
||||
state.rowById.clear();
|
||||
state.sightingCount = 0;
|
||||
ambientAnchorIds.clear();
|
||||
badgeScreens.clear();
|
||||
crowdedBadgeIds.clear();
|
||||
photoCardIds.clear();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
deactivate();
|
||||
clear();
|
||||
services.sprites?.unregisterSpriteCollection?.(
|
||||
'wildlife-sightings',
|
||||
state.points,
|
||||
);
|
||||
if (state.points) state.viewer?.scene?.primitives?.remove?.(state.points);
|
||||
state.points = null;
|
||||
billboards = null;
|
||||
density?.destroy();
|
||||
density = null;
|
||||
appearances.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
get density() {
|
||||
return density;
|
||||
},
|
||||
get pending() {
|
||||
return workQueue.pending;
|
||||
},
|
||||
init,
|
||||
activate,
|
||||
deactivate,
|
||||
occluder,
|
||||
cull,
|
||||
rebuild,
|
||||
refreshSelection,
|
||||
setPhotoCardVisibility,
|
||||
hasPhotoCard: (id) => photoCardIds.has(id),
|
||||
clear,
|
||||
destroy,
|
||||
requestRender,
|
||||
beginBatch,
|
||||
endBatch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { nearFarScalarValueAtDistance } from '../../data/focusDeemphasis.js';
|
||||
import { wildlifeGridCellId } from './gridViewport.js';
|
||||
import {
|
||||
WILDLIFE_GROUND_READY_MAX_CHECKS,
|
||||
WILDLIFE_SCALE_BY_DISTANCE,
|
||||
} from './policy.js';
|
||||
|
||||
const NOW = Date.parse('2026-11-04T18:00:00Z');
|
||||
const base = {
|
||||
stableId: 'synthetic:1',
|
||||
origin: 'inaturalist',
|
||||
lat: 47.6,
|
||||
lon: -122.3,
|
||||
iconic: 'Aves',
|
||||
observedAt: NOW - 60_000,
|
||||
quality: 'research',
|
||||
count: null,
|
||||
obscured: false,
|
||||
cell: null,
|
||||
};
|
||||
|
||||
function event() {
|
||||
const listeners = new Set();
|
||||
return {
|
||||
addEventListener(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
emit(...args) {
|
||||
for (const listener of [...listeners]) listener(...args);
|
||||
},
|
||||
get size() {
|
||||
return listeners.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function harness(rows = [base], extra = {}) {
|
||||
const counts = {
|
||||
collections: 0,
|
||||
created: 0,
|
||||
removed: 0,
|
||||
cleared: 0,
|
||||
writes: [],
|
||||
};
|
||||
const tracked = new Set([
|
||||
'position',
|
||||
'image',
|
||||
'color',
|
||||
'scale',
|
||||
'show',
|
||||
'width',
|
||||
'height',
|
||||
'scaleByDistance',
|
||||
]);
|
||||
class BillboardCollection extends Cesium.BillboardCollection {
|
||||
constructor() {
|
||||
super();
|
||||
counts.collections += 1;
|
||||
}
|
||||
add(options) {
|
||||
counts.created += 1;
|
||||
const point = super.add(options);
|
||||
return new Proxy(point, {
|
||||
get: (target, key) => Reflect.get(target, key, target),
|
||||
set(target, key, value) {
|
||||
if (tracked.has(key)) counts.writes.push(key);
|
||||
return Reflect.set(target, key, value, target);
|
||||
},
|
||||
});
|
||||
}
|
||||
remove(point) {
|
||||
counts.removed += 1;
|
||||
return super.remove(point);
|
||||
}
|
||||
removeAll() {
|
||||
counts.cleared += 1;
|
||||
return super.removeAll();
|
||||
}
|
||||
}
|
||||
class GroundBatch {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
this.ready = false;
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed === true;
|
||||
}
|
||||
}
|
||||
function primitives() {
|
||||
const values = [];
|
||||
return {
|
||||
values,
|
||||
get length() {
|
||||
return values.length;
|
||||
},
|
||||
get: (i) => values[i],
|
||||
add(primitive) {
|
||||
values.push(primitive);
|
||||
return primitive;
|
||||
},
|
||||
remove(primitive) {
|
||||
const i = values.indexOf(primitive);
|
||||
if (i < 0) return false;
|
||||
values.splice(i, 1);
|
||||
primitive.destroy();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
const postRender = event();
|
||||
const terrain = event();
|
||||
const tileProgress = event();
|
||||
const scene = {
|
||||
primitives: primitives(),
|
||||
groundPrimitives: primitives(),
|
||||
postRender,
|
||||
terrainProviderChanged: terrain,
|
||||
globe: {
|
||||
terrainProviderChanged: terrain,
|
||||
tileLoadProgressEvent: tileProgress,
|
||||
},
|
||||
};
|
||||
const state = {
|
||||
enabled: true,
|
||||
viewer: { scene, camera: { positionWC: {} } },
|
||||
rows: new Map(rows.map((row) => [row.stableId, row])),
|
||||
renderedRows: [],
|
||||
rowById: new Map(),
|
||||
pointById: new Map(),
|
||||
positions: new Map(),
|
||||
cells: [],
|
||||
staging: null,
|
||||
taxa: 'all',
|
||||
selectedId: null,
|
||||
query: { visibleBoxes: [{ south: -90, west: -180, north: 90, east: 180 }] },
|
||||
band: 'cards',
|
||||
};
|
||||
const renders = [];
|
||||
let visible = true;
|
||||
const registration = new Set();
|
||||
const rendering = createRendering({
|
||||
state,
|
||||
services: {
|
||||
now: () => NOW,
|
||||
cesium: {
|
||||
...Cesium,
|
||||
BillboardCollection,
|
||||
GroundPrimitive: GroundBatch,
|
||||
GroundPolylinePrimitive: GroundBatch,
|
||||
},
|
||||
occluderFactory: () => ({ isPointVisible: () => visible }),
|
||||
render: { governorRequestRender: (reason) => renders.push(reason) },
|
||||
sprites: {
|
||||
registerSpriteCollection: (_id, value) => registration.add(value),
|
||||
unregisterSpriteCollection: (_id, value) => registration.delete(value),
|
||||
},
|
||||
...extra,
|
||||
},
|
||||
parts: {
|
||||
selection: {
|
||||
clear() {
|
||||
state.selectedId = null;
|
||||
},
|
||||
refreshAmbient() {},
|
||||
publishSelected() {},
|
||||
refreshContexts() {},
|
||||
},
|
||||
},
|
||||
});
|
||||
rendering.init();
|
||||
rendering.activate();
|
||||
return {
|
||||
state,
|
||||
scene,
|
||||
rendering,
|
||||
counts,
|
||||
renders,
|
||||
postRender,
|
||||
terrain,
|
||||
tileProgress,
|
||||
registration,
|
||||
visible(value) {
|
||||
visible = value;
|
||||
},
|
||||
rows(next) {
|
||||
state.rows = new Map(next.map((row) => [row.stableId, row]));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('real Cesium billboard values update in place, and unchanged refreshes write nothing', () => {
|
||||
const second = { ...base, stableId: 'synthetic:2', lon: -120 };
|
||||
const h = harness([base, second]);
|
||||
h.rendering.rebuild();
|
||||
const collection = h.state.points;
|
||||
const incumbent = h.state.pointById.get(base.stableId);
|
||||
assert.equal(h.counts.collections, 1);
|
||||
assert.equal(h.counts.created, 2);
|
||||
assert.equal(incumbent.width * incumbent.scaleByDistance.farValue, 12);
|
||||
assert.deepEqual(
|
||||
incumbent.scaleByDistance,
|
||||
new Cesium.NearFarScalar(...WILDLIFE_SCALE_BY_DISTANCE),
|
||||
);
|
||||
assert.equal(incumbent.color.alpha, 1);
|
||||
h.counts.writes.length = 0;
|
||||
h.renders.length = 0;
|
||||
h.rows([{ ...second }, { ...base }]);
|
||||
assert.equal(h.rendering.rebuild(), false);
|
||||
assert.equal(h.rendering.cull(), false);
|
||||
assert.deepEqual(h.counts.writes, []);
|
||||
assert.deepEqual(h.renders, []);
|
||||
h.rows([{ ...base, lat: base.lat + 0.01 }, second]);
|
||||
assert.equal(h.rendering.rebuild(), true);
|
||||
assert.deepEqual(h.counts.writes, ['position']);
|
||||
assert.equal(h.state.pointById.get(base.stableId), incumbent);
|
||||
assert.equal(h.state.points, collection);
|
||||
h.rows([
|
||||
{ ...base, lat: base.lat + 0.01 },
|
||||
{ ...second, stableId: 'synthetic:3' },
|
||||
]);
|
||||
h.rendering.rebuild();
|
||||
assert.equal(h.counts.created, 3);
|
||||
assert.equal(h.counts.removed, 1);
|
||||
assert.equal(h.counts.cleared, 0);
|
||||
h.rendering.destroy();
|
||||
assert.equal(h.registration.size, 0);
|
||||
assert.equal(h.scene.primitives.length, 0);
|
||||
assert.equal(h.state.pointById.size, 0);
|
||||
assert.equal(h.terrain.size, 0);
|
||||
assert.equal(h.tileProgress.size, 0);
|
||||
});
|
||||
|
||||
test('horizon changes write only changed visibility, with one governor request per batch', () => {
|
||||
const h = harness();
|
||||
h.rendering.rebuild();
|
||||
h.counts.writes.length = 0;
|
||||
h.renders.length = 0;
|
||||
h.visible(false);
|
||||
h.rendering.beginBatch();
|
||||
h.rendering.cull();
|
||||
h.rendering.requestRender('labels');
|
||||
h.rendering.endBatch();
|
||||
assert.deepEqual(h.counts.writes, ['show']);
|
||||
assert.equal(h.renders.length, 1);
|
||||
h.rendering.cull();
|
||||
assert.deepEqual(h.counts.writes, ['show']);
|
||||
assert.equal(h.renders.length, 1);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('badge pixels stay between 12 and 22 at near, mid and far heights, including selection', () => {
|
||||
const h = harness();
|
||||
h.rendering.rebuild();
|
||||
const wildlife = h.state.pointById.get(base.stableId);
|
||||
for (const selectedId of [null, base.stableId]) {
|
||||
h.state.selectedId = selectedId;
|
||||
h.rendering.refreshSelection();
|
||||
for (const heightM of [
|
||||
0, 1000, 9000, 120_000, 400_000, 1_500_000, 8_000_000, 10_000_000,
|
||||
]) {
|
||||
const pixels =
|
||||
wildlife.width *
|
||||
wildlife.scale *
|
||||
nearFarScalarValueAtDistance(wildlife.scaleByDistance, heightM);
|
||||
assert.ok(
|
||||
pixels >= 12 && pixels <= 22,
|
||||
`${heightM}m, selected=${!!selectedId}: ${pixels}px`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
wildlife.color,
|
||||
Cesium.Color.WHITE,
|
||||
'keep the baked white glyph white',
|
||||
);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('adjacent 28px bucket edges never overlap badges or erase sighting counts', () => {
|
||||
const rows = [27, 29, 56, 84].map((x, i) => ({
|
||||
...base,
|
||||
stableId: `edge:${i}`,
|
||||
lon: base.lon + i * 0.01,
|
||||
x,
|
||||
}));
|
||||
const h = harness(rows, {
|
||||
project: (_scene, anchor) => {
|
||||
const lon = Cesium.Math.toDegrees(
|
||||
Cesium.Cartographic.fromCartesian(anchor).longitude,
|
||||
);
|
||||
return {
|
||||
x: rows.find((row) => Math.abs(row.lon - lon) < 1e-7).x,
|
||||
y: 100,
|
||||
};
|
||||
},
|
||||
});
|
||||
h.rendering.rebuild();
|
||||
assert.equal(h.state.sightingCount, 4);
|
||||
assert.equal(
|
||||
h.state.pointById.size,
|
||||
4,
|
||||
'all occupied buckets retain their identities',
|
||||
);
|
||||
const visible = rows.filter(
|
||||
(row) => h.state.pointById.get(row.stableId).show,
|
||||
);
|
||||
assert.equal(visible.length, 3);
|
||||
for (let i = 0; i < visible.length; i++)
|
||||
for (let j = i + 1; j < visible.length; j++)
|
||||
assert.ok(Math.abs(visible[i].x - visible[j].x) >= 22);
|
||||
h.counts.writes.length = 0;
|
||||
h.renders.length = 0;
|
||||
h.rendering.rebuild();
|
||||
h.rendering.cull();
|
||||
assert.deepEqual(h.counts.writes, []);
|
||||
assert.deepEqual(h.renders, []);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('only painted photo cards hide their anchored badges, with zero repeated visibility writes', () => {
|
||||
const h = harness();
|
||||
h.rendering.rebuild();
|
||||
const point = h.state.pointById.get(base.stableId);
|
||||
assert.equal(
|
||||
point.show,
|
||||
true,
|
||||
'a requested or loading card does not hide its badge',
|
||||
);
|
||||
h.counts.writes.length = 0;
|
||||
h.renders.length = 0;
|
||||
h.rendering.setPhotoCardVisibility(new Set([`sighting:${base.stableId}`]));
|
||||
assert.equal(point.show, false);
|
||||
assert.deepEqual(h.counts.writes, ['show']);
|
||||
h.rendering.rebuild();
|
||||
h.rendering.cull();
|
||||
h.rendering.setPhotoCardVisibility(new Set([`sighting:${base.stableId}`]));
|
||||
assert.deepEqual(h.counts.writes, ['show']);
|
||||
assert.equal(h.renders.length, 1);
|
||||
h.rendering.setPhotoCardVisibility(new Set());
|
||||
assert.equal(
|
||||
point.show,
|
||||
true,
|
||||
'a card that no longer paints restores its badge',
|
||||
);
|
||||
assert.deepEqual(h.counts.writes, ['show', 'show']);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('sampled fallback buckets allocate only representatives at every height, with zero idle writes', () => {
|
||||
for (const heightM of [1000, 7_000_000]) {
|
||||
const rows = ['a', 'b', 'c'].map((id, index) => ({
|
||||
...base,
|
||||
stableId: `synthetic:${id}`,
|
||||
lon: base.lon + index * 0.01,
|
||||
}));
|
||||
const screens = new Map(
|
||||
rows.map((row) => [row.lon.toFixed(6), { x: 100, y: 100 }]),
|
||||
);
|
||||
const h = harness(rows, {
|
||||
project: (_scene, anchor) => {
|
||||
const lon = Cesium.Math.toDegrees(
|
||||
Cesium.Cartographic.fromCartesian(anchor).longitude,
|
||||
);
|
||||
return screens.get(lon.toFixed(6));
|
||||
},
|
||||
});
|
||||
h.state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
base.lon,
|
||||
base.lat,
|
||||
heightM,
|
||||
);
|
||||
h.rendering.rebuild();
|
||||
assert.equal(
|
||||
h.state.pointById.size,
|
||||
1,
|
||||
'same bucket reduction near and far',
|
||||
);
|
||||
assert.equal(h.counts.created, 1);
|
||||
screens.set(rows[2].lon.toFixed(6), { x: 200, y: 100 });
|
||||
assert.equal(h.rendering.cull(), false);
|
||||
h.rendering.rebuild(); // moveEnd recomputes buckets, never an idle frame.
|
||||
assert.equal(h.state.pointById.size, 2);
|
||||
assert.equal(h.counts.created, 2);
|
||||
h.counts.writes.length = 0;
|
||||
assert.equal(h.rendering.cull(), false);
|
||||
assert.equal(h.rendering.rebuild(), false);
|
||||
assert.deepEqual(h.counts.writes, []);
|
||||
assert.equal(h.postRender.size, 0);
|
||||
h.rendering.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('UTFGrid creates a dedicated unpickable glow and observations alone create badges', () => {
|
||||
let projections = 0;
|
||||
const h = harness([base], {
|
||||
project() {
|
||||
projections++;
|
||||
return { x: 100, y: 100 };
|
||||
},
|
||||
});
|
||||
h.scene.canvas = { clientWidth: 280, clientHeight: 200 };
|
||||
h.state.gridAvailable = true;
|
||||
h.state.gridCells = [1, 50, 2].map((cellCount, index) => ({
|
||||
id: wildlifeGridCellId(base.lat, base.lon + index * 0.01, 16),
|
||||
lat: base.lat,
|
||||
lon: base.lon + index * 0.01,
|
||||
cellCount,
|
||||
}));
|
||||
h.rendering.rebuild();
|
||||
const glow = h.rendering.density.collection;
|
||||
assert.ok(glow);
|
||||
assert.equal(glow.allowPicking, false);
|
||||
assert.equal(glow.length, 3);
|
||||
assert.equal(h.scene.primitives.length, 2);
|
||||
assert.deepEqual([...h.state.pointById.keys()], [base.stableId]);
|
||||
assert.deepEqual(h.state.renderedRows, [base]);
|
||||
assert.equal(h.state.sightingCount, 53);
|
||||
assert.equal(projections, 1, 'only observations require screen projections');
|
||||
assert.equal(h.postRender.size, 0, 'density installs no frame listener');
|
||||
h.state.gridCells = [...h.state.gridCells].reverse();
|
||||
h.counts.writes.length = 0;
|
||||
assert.equal(h.rendering.rebuild(), false);
|
||||
assert.equal(h.rendering.density.collection, glow);
|
||||
assert.deepEqual(h.counts.writes, []);
|
||||
h.rendering.clear();
|
||||
assert.equal(glow.isDestroyed(), true);
|
||||
assert.equal(h.scene.primitives.length, 1);
|
||||
assert.equal(h.state.points.length, 0);
|
||||
h.rendering.destroy();
|
||||
assert.equal(h.scene.primitives.length, 0);
|
||||
});
|
||||
|
||||
test('tile arrival or loss cannot replace a real observation with a grid badge', () => {
|
||||
const h = harness();
|
||||
h.rendering.rebuild();
|
||||
const badge = h.state.pointById.get(base.stableId);
|
||||
h.state.gridAvailable = true;
|
||||
h.state.gridCells = [
|
||||
{
|
||||
id: wildlifeGridCellId(base.lat, base.lon, 2),
|
||||
lat: base.lat,
|
||||
lon: base.lon,
|
||||
cellCount: 10_000,
|
||||
},
|
||||
];
|
||||
h.rendering.rebuild();
|
||||
const glow = h.rendering.density.collection;
|
||||
assert.equal(h.state.sightingCount, 10_000);
|
||||
assert.equal(h.state.pointById.get(base.stableId), badge);
|
||||
h.state.gridAvailable = false;
|
||||
h.rendering.rebuild();
|
||||
assert.equal(h.state.pointById.get(base.stableId), badge);
|
||||
assert.equal(h.state.sightingCount, 1);
|
||||
assert.equal(glow.isDestroyed(), true);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('a selected observation remains rendered outside the viewport and observation window after a zoom', () => {
|
||||
const selected = { ...base, observedAt: NOW - 49 * 3_600_000 };
|
||||
const h = harness([]);
|
||||
h.state.selectedId = selected.stableId;
|
||||
h.state.selectedRow = selected;
|
||||
h.state.query.visibleBoxes = [{ south: 0, west: 0, north: 1, east: 1 }];
|
||||
h.rendering.rebuild();
|
||||
assert.deepEqual(h.state.renderedRows, [selected]);
|
||||
assert.equal(h.state.rowById.get(selected.stableId), selected);
|
||||
assert.equal(h.state.pointById.has(selected.stableId), true);
|
||||
h.state.band = 'regional';
|
||||
h.state.viewer.camera.positionCartographic = { height: 1_500_000 };
|
||||
h.rendering.rebuild();
|
||||
assert.equal(h.state.selectedId, selected.stableId);
|
||||
assert.equal(h.state.rowById.get(selected.stableId), selected);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('privacy ground staging has a bounded readiness listener and complete teardown', () => {
|
||||
const h = harness([
|
||||
{
|
||||
...base,
|
||||
obscured: true,
|
||||
cell: { south: 47.6, west: -122.4, north: 47.8, east: -122.2 },
|
||||
},
|
||||
]);
|
||||
h.rendering.rebuild();
|
||||
assert.equal(h.postRender.size, 1);
|
||||
assert.equal(h.scene.groundPrimitives.length, 2);
|
||||
for (let i = 0; i < WILDLIFE_GROUND_READY_MAX_CHECKS; i++)
|
||||
h.postRender.emit();
|
||||
assert.equal(h.postRender.size, 0);
|
||||
assert.equal(h.state.staging, null);
|
||||
assert.equal(h.scene.groundPrimitives.length, 0);
|
||||
h.rendering.destroy();
|
||||
assert.equal(h.postRender.size, 0);
|
||||
});
|
||||
|
||||
test('font readiness swaps images in place without billboard recreation or position work', () => {
|
||||
const listeners = new Set();
|
||||
let ready = false;
|
||||
const badgeSprites = {
|
||||
get: (group) =>
|
||||
`data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg"><circle r="${ready ? 9 : 8}" data-group="${group}"/></svg>`)}`,
|
||||
subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
};
|
||||
const rows = [
|
||||
base,
|
||||
{ ...base, stableId: 'synthetic:mammal', iconic: 'Mammalia', lon: -120 },
|
||||
];
|
||||
const h = harness(rows, { badgeSprites });
|
||||
h.rendering.rebuild();
|
||||
const collection = h.state.points;
|
||||
const points = new Map(h.state.pointById);
|
||||
const before = new Map([...points].map(([id, point]) => [id, point.image]));
|
||||
h.counts.writes.length = 0;
|
||||
h.renders.length = 0;
|
||||
ready = true;
|
||||
for (const listener of listeners) listener();
|
||||
assert.equal(h.state.points, collection);
|
||||
assert.equal(h.counts.created, 2);
|
||||
assert.equal(h.counts.removed, 0);
|
||||
assert.equal(h.counts.cleared, 0);
|
||||
assert.deepEqual(h.counts.writes, ['image', 'image']);
|
||||
assert.equal(h.renders.length, 1);
|
||||
for (const [id, point] of points) {
|
||||
assert.equal(h.state.pointById.get(id), point);
|
||||
assert.notEqual(point.image, before.get(id));
|
||||
}
|
||||
h.counts.writes.length = 0;
|
||||
h.rows(rows.map((row) => ({ ...row, lat: row.lat + 0.01 })));
|
||||
h.rendering.rebuild();
|
||||
assert.deepEqual(h.counts.writes, ['position', 'position']);
|
||||
h.rendering.destroy();
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,560 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { wildlifeMeasure } from './performance.js';
|
||||
import { compareWildlifeIds } from './model.js';
|
||||
import { isRecentWildlifeSighting } from './records.js';
|
||||
import {
|
||||
ACARTIA_CREDIT,
|
||||
registerDynamicCredit,
|
||||
} from '../../data/dataCredits.js';
|
||||
import {
|
||||
WILDLIFE_OVERLAY_SOURCE_ID,
|
||||
WILDLIFE_SELECTED_SOURCE_ID,
|
||||
WILDLIFE_LABEL_SOURCE_ID,
|
||||
WILDLIFE_LABEL_SOURCE_OPTIONS,
|
||||
WILDLIFE_OVERLAY_SOURCE_OPTIONS,
|
||||
WILDLIFE_SELECTED_SOURCE_OPTIONS,
|
||||
buildWildlifeThumbnailEntry,
|
||||
buildWildlifeSelectedLabel,
|
||||
buildWildlifeLabelEntry,
|
||||
wildlifeCommonName,
|
||||
wildlifeDisplayName,
|
||||
wildlifeLabelScore,
|
||||
selectWildlifeAmbientRows,
|
||||
} from './cards.js';
|
||||
import {
|
||||
WILDLIFE_CARD_LIMIT,
|
||||
WILDLIFE_CARD_SHOW_MAX_METERS,
|
||||
WILDLIFE_CARD_HIDE_MAX_METERS,
|
||||
WILDLIFE_LABEL_NAME_SEPARATION_PX,
|
||||
WILDLIFE_SELECTED_LABEL_FADE_HIGH_METERS,
|
||||
WILDLIFE_LABEL_LIMIT,
|
||||
} from './policy.js';
|
||||
|
||||
const CONTEXT_LIMIT = 30;
|
||||
|
||||
/** Overlay sources, pinned observation selection and external focus. */
|
||||
export function createSelection({ state, services, parts }) {
|
||||
const overlay = services.overlayHost;
|
||||
const sourceIds = [
|
||||
WILDLIFE_OVERLAY_SOURCE_ID,
|
||||
WILDLIFE_LABEL_SOURCE_ID,
|
||||
WILDLIFE_SELECTED_SOURCE_ID,
|
||||
];
|
||||
const listeners = new Set();
|
||||
// Coordinates are refreshed only by the existing camera/data label pass.
|
||||
const labelScreens = new Map();
|
||||
const ambientSourceOptions = {
|
||||
...WILDLIFE_OVERLAY_SOURCE_OPTIONS,
|
||||
onVisibilityChange: (ids) => parts.rendering.setPhotoCardVisibility?.(ids),
|
||||
};
|
||||
|
||||
function renderBatch(fn) {
|
||||
parts.rendering.beginBatch?.();
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
parts.rendering.endBatch?.();
|
||||
}
|
||||
}
|
||||
|
||||
function selectionRow() {
|
||||
if (!state.enabled || !state.selectedId) return null;
|
||||
return state.selectedRow || state.rowById.get(state.selectedId) || null;
|
||||
}
|
||||
|
||||
function dispatch(listener, row, metadata) {
|
||||
try {
|
||||
listener(row, metadata);
|
||||
} catch {
|
||||
// A disconnected UI surface must not prevent map selection or teardown.
|
||||
}
|
||||
}
|
||||
|
||||
function notify({ explicitSelection = false } = {}) {
|
||||
const row = selectionRow();
|
||||
const metadata = { explicitSelection: explicitSelection === true };
|
||||
for (const listener of listeners) dispatch(listener, row, metadata);
|
||||
}
|
||||
|
||||
function subscribe(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
if (!state.destroyed) listeners.add(listener);
|
||||
dispatch(listener, selectionRow(), { explicitSelection: false });
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
const cardActions = {
|
||||
select: selectAndFocus,
|
||||
hasRow: (id) => state.enabled && state.rowById.has(id),
|
||||
};
|
||||
|
||||
function publishAmbient() {
|
||||
return wildlifeMeasure('overlay-publish', publishAmbientEntries);
|
||||
}
|
||||
|
||||
function publishAmbientEntries() {
|
||||
if (!state.enabled || !overlay) return;
|
||||
overlay.setEntries(
|
||||
WILDLIFE_OVERLAY_SOURCE_ID,
|
||||
state.ambientRows.map((row) => ({
|
||||
...buildWildlifeThumbnailEntry(row, services.now(), {
|
||||
...cardActions,
|
||||
image: parts.photos.get(row),
|
||||
selected: row.stableId === state.selectedId,
|
||||
}),
|
||||
position: state.positions.get(row.stableId),
|
||||
})),
|
||||
ambientSourceOptions,
|
||||
);
|
||||
parts.rendering.requestRender('photos');
|
||||
}
|
||||
|
||||
function publishSelected() {
|
||||
return wildlifeMeasure('overlay-publish', publishSelectedEntries);
|
||||
}
|
||||
|
||||
function publishSelectedEntries() {
|
||||
if (!state.enabled || !overlay) return;
|
||||
const row = selectionRow();
|
||||
// The selected sighting's painted thumbnail already shows its name.
|
||||
const label =
|
||||
row && !parts.rendering.hasPhotoCard?.(row.stableId)
|
||||
? buildWildlifeSelectedLabel(row)
|
||||
: null;
|
||||
overlay.setEntries(
|
||||
WILDLIFE_SELECTED_SOURCE_ID,
|
||||
label
|
||||
? [
|
||||
{
|
||||
...label,
|
||||
position: state.positions.get(row.stableId),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
WILDLIFE_SELECTED_SOURCE_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
function publishLabels() {
|
||||
return wildlifeMeasure('overlay-publish', publishLabelsEntries);
|
||||
}
|
||||
|
||||
function publishLabelsEntries() {
|
||||
if (!state.enabled || !overlay) return;
|
||||
const names = new Map();
|
||||
const remember = (row) => {
|
||||
const screen = labelScreens.get(row.stableId);
|
||||
if (!screen) return;
|
||||
const name = wildlifeDisplayName(row);
|
||||
if (!names.has(name)) names.set(name, []);
|
||||
names.get(name).push(screen);
|
||||
};
|
||||
// Painted photos win regardless of score. Queued or rejected images do not
|
||||
// suppress neighbors. Selection wins over ambient names using the same cache.
|
||||
for (const row of state.ambientRows)
|
||||
if (parts.rendering.hasPhotoCard?.(row.stableId)) remember(row);
|
||||
const selected = selectionRow();
|
||||
if (
|
||||
selected &&
|
||||
(state.viewer.camera?.positionCartographic?.height ?? 0) <
|
||||
WILDLIFE_SELECTED_LABEL_FADE_HIGH_METERS
|
||||
)
|
||||
remember(selected);
|
||||
const entries = [];
|
||||
for (const row of state.labelRows || []) {
|
||||
if (
|
||||
row.stableId === state.selectedId ||
|
||||
parts.rendering.hasPhotoCard?.(row.stableId)
|
||||
)
|
||||
continue;
|
||||
const screen = labelScreens.get(row.stableId);
|
||||
const nearby = names.get(wildlifeDisplayName(row));
|
||||
if (
|
||||
screen &&
|
||||
nearby?.some(
|
||||
(other) =>
|
||||
(screen.x - other.x) ** 2 + (screen.y - other.y) ** 2 <
|
||||
WILDLIFE_LABEL_NAME_SEPARATION_PX ** 2,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const entry = buildWildlifeLabelEntry(row, {
|
||||
...cardActions,
|
||||
nowMs: services.now(),
|
||||
});
|
||||
if (!entry) continue;
|
||||
entries.push({ ...entry, position: state.positions.get(row.stableId) });
|
||||
remember(row);
|
||||
if (entries.length >= WILDLIFE_LABEL_LIMIT) break;
|
||||
}
|
||||
overlay.setEntries(
|
||||
WILDLIFE_LABEL_SOURCE_ID,
|
||||
entries,
|
||||
WILDLIFE_LABEL_SOURCE_OPTIONS,
|
||||
);
|
||||
parts.rendering.requestRender('labels');
|
||||
}
|
||||
|
||||
function refreshLabels() {
|
||||
if (!state.enabled || !overlay) return;
|
||||
const scene = state.viewer.scene;
|
||||
const horizon = parts.rendering.occluder();
|
||||
const width = scene.canvas?.clientWidth || 0;
|
||||
const height = scene.canvas?.clientHeight || 0;
|
||||
// Rank during camera/data passes; the host handles pixel collisions.
|
||||
const nowMs = services.now();
|
||||
const candidates = [];
|
||||
labelScreens.clear();
|
||||
const rows = new Map(state.renderedRows.map((row) => [row.stableId, row]));
|
||||
for (const row of state.ambientRows) rows.set(row.stableId, row);
|
||||
for (const row of rows.values()) {
|
||||
if (!wildlifeCommonName(row)) continue;
|
||||
const position = state.positions.get(row.stableId);
|
||||
if (!position || (horizon && horizon.isPointVisible(position) !== true))
|
||||
continue;
|
||||
const screen = services.project(scene, position);
|
||||
if (
|
||||
!screen ||
|
||||
screen.x < 0 ||
|
||||
screen.y < 0 ||
|
||||
screen.x > width ||
|
||||
screen.y > height
|
||||
)
|
||||
continue;
|
||||
labelScreens.set(row.stableId, { x: screen.x, y: screen.y });
|
||||
candidates.push(row);
|
||||
}
|
||||
candidates.sort(
|
||||
(a, b) =>
|
||||
wildlifeLabelScore(b, nowMs) - wildlifeLabelScore(a, nowMs) ||
|
||||
compareWildlifeIds(a.stableId, b.stableId),
|
||||
);
|
||||
// Keep ranked candidates so a disappearing photo or cleared selection can
|
||||
// restore suppressed names without another projection pass. Cap after dedup.
|
||||
state.labelRows = candidates;
|
||||
publishLabels();
|
||||
}
|
||||
|
||||
function ambientCandidates(rows) {
|
||||
const scene = state.viewer.scene;
|
||||
const horizon = parts.rendering.occluder();
|
||||
const width = scene.canvas?.clientWidth ?? Infinity;
|
||||
const height = scene.canvas?.clientHeight ?? Infinity;
|
||||
const screens = new Map();
|
||||
const nowMs = services.now();
|
||||
const taxa = state.drawnParams?.taxa ?? state.taxa;
|
||||
const eligible = rows.filter((row) => {
|
||||
if (
|
||||
!row.photo ||
|
||||
row.obscured ||
|
||||
!isRecentWildlifeSighting(row, nowMs, taxa)
|
||||
)
|
||||
return false;
|
||||
const position = state.positions.get(row.stableId);
|
||||
if (!position || (horizon && horizon.isPointVisible(position) !== true))
|
||||
return false;
|
||||
const screen = services.project(scene, position);
|
||||
if (
|
||||
!screen ||
|
||||
screen.x < 0 ||
|
||||
screen.y < 0 ||
|
||||
screen.x > width ||
|
||||
screen.y > height
|
||||
)
|
||||
return false;
|
||||
screens.set(row.stableId, { x: screen.x, y: screen.y });
|
||||
return true;
|
||||
});
|
||||
return { eligible, screens, width, height };
|
||||
}
|
||||
|
||||
/** Snapshot before acquisition replaces its sample. Only currently visible
|
||||
* photos survive; acquisition also checks the observation window/params. */
|
||||
function retainedAmbientRows() {
|
||||
if (!state.enabled) return [];
|
||||
const height =
|
||||
state.viewer?.camera?.positionCartographic?.height ?? Infinity;
|
||||
if (height >= WILDLIFE_CARD_HIDE_MAX_METERS) return [];
|
||||
return ambientCandidates(state.ambientRows).eligible;
|
||||
}
|
||||
|
||||
function refreshAmbient() {
|
||||
if (!state.enabled) return;
|
||||
refreshLabels();
|
||||
const height =
|
||||
state.viewer?.camera?.positionCartographic?.height ?? Infinity;
|
||||
if (state.ambientActive) {
|
||||
if (height >= WILDLIFE_CARD_HIDE_MAX_METERS) state.ambientActive = false;
|
||||
} else if (height <= WILDLIFE_CARD_SHOW_MAX_METERS)
|
||||
state.ambientActive = true;
|
||||
if (!state.ambientActive) {
|
||||
state.ambientRows = [];
|
||||
state.ambientIds = [];
|
||||
parts.photos.setAmbient([]);
|
||||
publishAmbient();
|
||||
return;
|
||||
}
|
||||
// A bucket rebuild may hide an incumbent behind a newly sampled neighbor.
|
||||
// Retain its actual loaded row; rendering gives ambient IDs bucket priority.
|
||||
const byId = new Map(state.renderedRows.map((row) => [row.stableId, row]));
|
||||
for (const row of state.ambientRows) {
|
||||
const current = state.rowById.get(row.stableId);
|
||||
if (current) byId.set(row.stableId, current);
|
||||
}
|
||||
const candidates = ambientCandidates([...byId.values()]);
|
||||
state.ambientRows = selectWildlifeAmbientRows(
|
||||
candidates.eligible,
|
||||
state.ambientIds,
|
||||
services.now(),
|
||||
WILDLIFE_CARD_LIMIT,
|
||||
candidates,
|
||||
);
|
||||
state.ambientIds = state.ambientRows.map((row) => row.stableId);
|
||||
parts.photos.setAmbient(state.ambientRows);
|
||||
publishAmbient();
|
||||
}
|
||||
|
||||
// iNaturalist is static; Acartia enters the attribution popover on first use.
|
||||
function syncCredits() {
|
||||
if (!state.enabled) return;
|
||||
if (state.renderedRows.some((row) => row.origin === 'acartia'))
|
||||
registerDynamicCredit(state.viewer, ACARTIA_CREDIT);
|
||||
}
|
||||
|
||||
function registerContext(row) {
|
||||
if (!services.context?.registerEntityContext) return null;
|
||||
let carrier = state.carriers.get(row.stableId);
|
||||
if (!carrier) {
|
||||
carrier = { show: true };
|
||||
state.carriers.set(row.stableId, carrier);
|
||||
}
|
||||
carrier.__localBaseCartesian = state.positions.get(row.stableId);
|
||||
// A badge yielding to its photo or a neighbor is still a visible sighting.
|
||||
// Context visibility follows the anchor, independently of badge declutter.
|
||||
const horizon = parts.rendering.occluder();
|
||||
carrier.show =
|
||||
!horizon || horizon.isPointVisible(carrier.__localBaseCartesian) === true;
|
||||
const name = row.commonName || row.scientificName || t('Cetacean');
|
||||
// i18n-ignore: provider names and feed-supplied organism names are verbatim
|
||||
const label = `${name} · ${row.origin === 'acartia' ? 'Acartia' : 'iNaturalist'}`;
|
||||
services.context.registerEntityContext(carrier, {
|
||||
id: row.stableId,
|
||||
layerId: 'wildlife-sightings',
|
||||
layerName: t('Wildlife Sightings'),
|
||||
source: row.origin === 'acartia' ? 'Acartia' : 'iNaturalist',
|
||||
label,
|
||||
latitude: row.lat,
|
||||
longitude: row.lon,
|
||||
properties: {
|
||||
quality: row.quality,
|
||||
iconic: row.iconic,
|
||||
obscured: row.obscured,
|
||||
},
|
||||
select: () => selectAndFocus(row.stableId),
|
||||
});
|
||||
return carrier;
|
||||
}
|
||||
|
||||
function refreshContexts() {
|
||||
if (!services.context) return;
|
||||
const rows = state.renderedRows.slice(0, CONTEXT_LIMIT);
|
||||
const selected = selectionRow();
|
||||
if (selected && !rows.includes(selected)) rows.push(selected);
|
||||
const next = new Set(rows.map((row) => row.stableId));
|
||||
try {
|
||||
for (const row of rows) registerContext(row);
|
||||
const store = services.context.getContextStore?.();
|
||||
for (const id of state.contextIds) {
|
||||
if (!next.has(id)) {
|
||||
if (store?.entities?.get(id)?.layerId === 'wildlife-sightings')
|
||||
store.entities.delete(id);
|
||||
state.carriers.delete(id);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Optional context surfaces must not prevent data or cards rendering.
|
||||
}
|
||||
state.contextIds = next;
|
||||
}
|
||||
|
||||
function select(id, { publishContext = true } = {}) {
|
||||
return renderBatch(() => selectRecord(id, { publishContext }));
|
||||
}
|
||||
|
||||
function selectRecord(id, { publishContext = true } = {}) {
|
||||
if (!state.enabled || !state.rowById.has(id)) return false;
|
||||
const row = state.rowById.get(id);
|
||||
state.selectedId = id;
|
||||
state.selectedRow = row;
|
||||
parts.rendering.refreshSelection();
|
||||
publishAmbient();
|
||||
publishLabels();
|
||||
publishSelected();
|
||||
try {
|
||||
const carrier = registerContext(row);
|
||||
state.contextIds.add(id);
|
||||
if (publishContext && carrier) {
|
||||
state.publishingContext = true;
|
||||
try {
|
||||
services.context?.selectEntityContext?.(carrier);
|
||||
} finally {
|
||||
state.publishingContext = false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
state.publishingContext = false;
|
||||
}
|
||||
notify({ explicitSelection: true });
|
||||
parts.rendering.requestRender('select');
|
||||
return true;
|
||||
}
|
||||
|
||||
function selectAndFocus(id, options = {}) {
|
||||
const row = state.rowById.get(typeof id === 'string' ? id : id?.stableId);
|
||||
if (!state.enabled || !row) return false;
|
||||
if (!select(row.stableId, options)) return false;
|
||||
services.focus?.requestWorldFocus?.({
|
||||
kind: row.obscured ? 'sighting-area' : 'sighting',
|
||||
id: row.stableId,
|
||||
label: row.commonName || row.scientificName || t('SIGHTING'),
|
||||
position: state.positions.get(row.stableId),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
return renderBatch(clearRecord);
|
||||
}
|
||||
|
||||
function clearRecord() {
|
||||
const hadSelection = state.selectedId !== null;
|
||||
state.selectedId = null;
|
||||
state.selectedRow = null;
|
||||
try {
|
||||
services.context?.clearSelectedEntityContextForLayer?.(
|
||||
'wildlife-sightings',
|
||||
);
|
||||
} catch {
|
||||
/* optional context host is already gone */
|
||||
}
|
||||
if (state.enabled) {
|
||||
if (hadSelection) publishAmbient();
|
||||
publishSelected();
|
||||
if (hadSelection) publishLabels();
|
||||
if (hadSelection) parts.rendering.requestRender('select');
|
||||
}
|
||||
notify();
|
||||
return hadSelection;
|
||||
}
|
||||
|
||||
function clearAndRebuild() {
|
||||
return renderBatch(() => {
|
||||
const hadSelection = clear();
|
||||
if (hadSelection) parts.rendering.refreshSelection();
|
||||
return hadSelection;
|
||||
});
|
||||
}
|
||||
|
||||
function install() {
|
||||
const onContext = (event) => {
|
||||
if (!state.enabled || state.publishingContext) return;
|
||||
const record =
|
||||
event.detail || services.context?.getSelectedEntityContext?.();
|
||||
if (record?.layerId === 'wildlife-sightings')
|
||||
selectAndFocus(record.id, { publishContext: false });
|
||||
};
|
||||
services.eventTarget?.addEventListener?.('gev:entity-selected', onContext);
|
||||
state.removeContext = () =>
|
||||
services.eventTarget?.removeEventListener?.(
|
||||
'gev:entity-selected',
|
||||
onContext,
|
||||
);
|
||||
if (typeof services.screenSpaceEventHandlerFactory !== 'function') return;
|
||||
state.clickHandler = services.screenSpaceEventHandlerFactory(state.viewer);
|
||||
state.clickHandler.setInputAction((click) => {
|
||||
if (!state.enabled || services.pointer?.isPointerFree?.() === false)
|
||||
return;
|
||||
const hit =
|
||||
overlay?.hitTest?.(click.position?.x, click.position?.y, {
|
||||
sourceId: WILDLIFE_SELECTED_SOURCE_ID,
|
||||
}) ||
|
||||
overlay?.hitTest?.(click.position?.x, click.position?.y, {
|
||||
sourceId: WILDLIFE_OVERLAY_SOURCE_ID,
|
||||
}) ||
|
||||
overlay?.hitTest?.(click.position?.x, click.position?.y, {
|
||||
sourceId: WILDLIFE_LABEL_SOURCE_ID,
|
||||
});
|
||||
if (hit) {
|
||||
hit.entry?.activate?.();
|
||||
return;
|
||||
}
|
||||
const picked = state.viewer.scene.pick(click.position);
|
||||
const id =
|
||||
services.picking?.resolvePickId?.(picked) ??
|
||||
picked?.id ??
|
||||
picked?.primitive?.id;
|
||||
if (
|
||||
typeof id === 'string' &&
|
||||
id.startsWith('wildlife:') &&
|
||||
selectAndFocus(id.slice(9))
|
||||
)
|
||||
return;
|
||||
if (
|
||||
id &&
|
||||
services.picking?.isOwnedByOtherLayer?.('wildlife-sightings', id)
|
||||
)
|
||||
return;
|
||||
clearAndRebuild();
|
||||
}, services.cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
|
||||
function remove() {
|
||||
state.clickHandler?.destroy();
|
||||
state.clickHandler = null;
|
||||
state.removeContext?.();
|
||||
state.removeContext = null;
|
||||
clear();
|
||||
try {
|
||||
services.context?.removeEntityContextsForLayer?.('wildlife-sightings');
|
||||
} catch {
|
||||
/* optional context host is already gone */
|
||||
}
|
||||
state.carriers.clear();
|
||||
state.contextIds.clear();
|
||||
state.ambientIds = [];
|
||||
state.ambientRows = [];
|
||||
state.ambientActive = false;
|
||||
state.labelRows = [];
|
||||
labelScreens.clear();
|
||||
for (const id of sourceIds) {
|
||||
overlay?.clearSource?.(id);
|
||||
overlay?.setVisible?.(id, false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selectAndFocus,
|
||||
clear,
|
||||
clearAndRebuild,
|
||||
subscribe,
|
||||
notify,
|
||||
destroy() {
|
||||
listeners.clear();
|
||||
},
|
||||
install,
|
||||
remove,
|
||||
refreshAmbient,
|
||||
retainedAmbientRows,
|
||||
publishAmbient,
|
||||
publishSelected,
|
||||
photoCardsChanged() {
|
||||
renderBatch(() => {
|
||||
publishLabels();
|
||||
publishSelected();
|
||||
});
|
||||
},
|
||||
refreshContexts,
|
||||
syncCredits,
|
||||
sourceIds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createSelection } from './selection.js';
|
||||
|
||||
const row = {
|
||||
stableId: 'inat:123',
|
||||
origin: 'inaturalist',
|
||||
lat: 48.1,
|
||||
lon: 8.2,
|
||||
commonName: 'Synthetic bird',
|
||||
iconic: 'Aves',
|
||||
observedAt: Date.UTC(2026, 10, 4),
|
||||
link: 'https://www.inaturalist.org/observations/123',
|
||||
};
|
||||
|
||||
function fixture(sighting = row) {
|
||||
const position = { x: 300, y: 300, z: 0 };
|
||||
const entries = new Map();
|
||||
const flights = [];
|
||||
const seen = [];
|
||||
const painted = new Set();
|
||||
let hit = null;
|
||||
let projections = 0;
|
||||
let picked = null;
|
||||
let click;
|
||||
const state = {
|
||||
enabled: true,
|
||||
destroyed: false,
|
||||
selectedId: null,
|
||||
selectedRow: null,
|
||||
rowById: new Map([[sighting.stableId, sighting]]),
|
||||
positions: new Map([[sighting.stableId, position]]),
|
||||
renderedRows: [sighting],
|
||||
carriers: new Map(),
|
||||
contextIds: new Set(),
|
||||
ambientIds: [],
|
||||
ambientRows: [],
|
||||
labelRows: [],
|
||||
viewer: {
|
||||
scene: {
|
||||
pick: () => picked,
|
||||
canvas: { clientWidth: 1200, clientHeight: 900 },
|
||||
},
|
||||
camera: { positionCartographic: { height: 30_000 } },
|
||||
},
|
||||
};
|
||||
const selection = createSelection({
|
||||
state,
|
||||
services: {
|
||||
overlayHost: {
|
||||
setEntries: (id, value) => entries.set(id, value),
|
||||
clearSource: (id) => entries.delete(id),
|
||||
setVisible() {},
|
||||
hitTest: (_x, _y, { sourceId }) =>
|
||||
hit?.sourceId === sourceId ? hit : null,
|
||||
},
|
||||
now: () => row.observedAt + 60_000,
|
||||
project: (_scene, anchor) => {
|
||||
projections++;
|
||||
return anchor;
|
||||
},
|
||||
focus: { requestWorldFocus: (request) => flights.push(request) },
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction: (fn) => (click = fn),
|
||||
destroy() {},
|
||||
}),
|
||||
cesium: { ScreenSpaceEventType: { LEFT_CLICK: 0 } },
|
||||
eventTarget: new EventTarget(),
|
||||
},
|
||||
parts: {
|
||||
rendering: {
|
||||
refreshSelection() {},
|
||||
requestRender() {},
|
||||
occluder: () => null,
|
||||
hasPhotoCard: (id) => painted.has(id),
|
||||
},
|
||||
photos: { get: () => ({ width: 75, height: 75 }), setAmbient() {} },
|
||||
},
|
||||
});
|
||||
selection.subscribe((value, metadata) => seen.push({ value, metadata }));
|
||||
return {
|
||||
state,
|
||||
selection,
|
||||
position,
|
||||
flights,
|
||||
entries,
|
||||
seen,
|
||||
get projections() {
|
||||
return projections;
|
||||
},
|
||||
hit(sourceId, entryId) {
|
||||
const entry = entries
|
||||
.get(sourceId)
|
||||
?.find((entry) => entry.id === entryId);
|
||||
hit = entry ? { sourceId, entryId, entry } : null;
|
||||
},
|
||||
paint(ids) {
|
||||
painted.clear();
|
||||
for (const id of ids) painted.add(id);
|
||||
selection.photoCardsChanged();
|
||||
},
|
||||
click(id = `wildlife:${sighting.stableId}`) {
|
||||
picked = id ? { id } : null;
|
||||
click({ position: { x: 10, y: 20 } });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a real sighting click pins its row, opens its label and focuses its true position', () => {
|
||||
const f = fixture();
|
||||
f.selection.install();
|
||||
f.click();
|
||||
assert.equal(f.state.selectedId, row.stableId);
|
||||
assert.equal(f.state.selectedRow, row);
|
||||
assert.equal(f.flights.length, 1);
|
||||
assert.deepEqual(f.flights[0], {
|
||||
kind: 'sighting',
|
||||
id: row.stableId,
|
||||
label: row.commonName,
|
||||
position: f.position,
|
||||
});
|
||||
assert.deepEqual(f.seen.at(-1), {
|
||||
value: row,
|
||||
metadata: { explicitSelection: true },
|
||||
});
|
||||
assert.equal(f.entries.get('wildlife-sightings-selected').length, 1);
|
||||
f.selection.remove();
|
||||
});
|
||||
|
||||
test('selected photo and name cards use the selected lane, select on click and never open links themselves', () => {
|
||||
for (const photo of [
|
||||
null,
|
||||
{
|
||||
square:
|
||||
'https://inaturalist-open-data.s3.amazonaws.com/photos/123/square.jpg',
|
||||
licence: 'cc-by',
|
||||
attribution: 'Synthetic Photographer',
|
||||
},
|
||||
]) {
|
||||
const sighting = { ...row, photo };
|
||||
const f = fixture(sighting);
|
||||
f.selection.install();
|
||||
f.selection.refreshAmbient();
|
||||
const source = photo ? 'wildlife-sightings' : 'wildlife-sightings-labels';
|
||||
const id = photo
|
||||
? 'sighting:inat:123'
|
||||
: 'wildlife-sightings-label:inat:123';
|
||||
f.hit(source, id);
|
||||
f.click();
|
||||
assert.equal(f.flights.length, 1);
|
||||
if (photo) f.paint([row.stableId]);
|
||||
const selectedSource = photo ? source : 'wildlife-sightings-selected';
|
||||
const card = f.entries.get(selectedSource)[0];
|
||||
assert.equal(card.selected, true);
|
||||
assert.equal(card.paintLane, 'selected');
|
||||
assert.ok(
|
||||
!(card.details || []).some((line) => line.includes('↗')),
|
||||
'the anchored photo card owns the single source link',
|
||||
);
|
||||
assert.match(card.accessibilityLabel, /Synthetic bird/);
|
||||
if (photo) {
|
||||
assert.equal(card.variant, 'thumbnail');
|
||||
assert.ok(card.image);
|
||||
assert.deepEqual(f.entries.get('wildlife-sightings-selected'), []);
|
||||
}
|
||||
f.hit(selectedSource, card.id);
|
||||
f.click('other:aircraft');
|
||||
assert.equal(f.flights.length, photo ? 2 : 1);
|
||||
f.selection.clearAndRebuild();
|
||||
if (photo) {
|
||||
const ambient = f.entries.get(source)[0];
|
||||
assert.equal(ambient.selected, undefined);
|
||||
assert.deepEqual(ambient.details, ['© Synthetic Photographer']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('nearby duplicate display names yield to painted cards and higher-ranked labels using cached screens', () => {
|
||||
const low = {
|
||||
...row,
|
||||
stableId: 'inat:low',
|
||||
quality: 'research',
|
||||
photo: {
|
||||
square:
|
||||
'https://inaturalist-open-data.s3.amazonaws.com/photos/123/square.jpg',
|
||||
licence: 'cc-by',
|
||||
attribution: 'Synthetic Photographer',
|
||||
},
|
||||
};
|
||||
const high = { ...low, stableId: 'inat:high', favesCount: 50 };
|
||||
const far = { ...row, stableId: 'inat:far' };
|
||||
const different = {
|
||||
...row,
|
||||
stableId: 'inat:different',
|
||||
commonName: 'Other bird',
|
||||
};
|
||||
const f = fixture();
|
||||
f.state.renderedRows = [low, high, far, different];
|
||||
f.state.rowById = new Map(
|
||||
f.state.renderedRows.map((row) => [row.stableId, row]),
|
||||
);
|
||||
f.state.positions = new Map([
|
||||
[low.stableId, { x: 300, y: 300 }],
|
||||
[high.stableId, { x: 379, y: 300 }],
|
||||
[far.stableId, { x: 460, y: 300 }],
|
||||
[different.stableId, { x: 310, y: 300 }],
|
||||
]);
|
||||
const labelIds = () =>
|
||||
f.entries.get('wildlife-sightings-labels').map((entry) => entry.id);
|
||||
f.selection.refreshAmbient();
|
||||
assert.ok(!labelIds().includes('wildlife-sightings-label:inat:low'));
|
||||
assert.ok(labelIds().includes('wildlife-sightings-label:inat:high'));
|
||||
assert.ok(
|
||||
labelIds().includes('wildlife-sightings-label:inat:far'),
|
||||
'81px names survive',
|
||||
);
|
||||
assert.ok(labelIds().includes('wildlife-sightings-label:inat:different'));
|
||||
const projections = f.projections;
|
||||
f.state.ambientRows = [low];
|
||||
f.paint([low.stableId]);
|
||||
assert.ok(
|
||||
!labelIds().includes('wildlife-sightings-label:inat:high'),
|
||||
'a painted lower-score card wins',
|
||||
);
|
||||
assert.equal(
|
||||
f.projections,
|
||||
projections,
|
||||
'paint membership uses the last label pass',
|
||||
);
|
||||
f.paint([]);
|
||||
assert.ok(
|
||||
labelIds().includes('wildlife-sightings-label:inat:high'),
|
||||
'a queued/unpainted card suppresses nothing',
|
||||
);
|
||||
f.selection.selectAndFocus(low.stableId);
|
||||
assert.ok(
|
||||
!labelIds().includes('wildlife-sightings-label:inat:high'),
|
||||
'selected labels outrank ambient names',
|
||||
);
|
||||
assert.equal(
|
||||
f.projections,
|
||||
projections,
|
||||
'selection never projects the population',
|
||||
);
|
||||
f.selection.clearAndRebuild();
|
||||
f.state.positions.set(high.stableId, { x: 600, y: 300 });
|
||||
f.selection.refreshAmbient();
|
||||
assert.ok(
|
||||
labelIds().includes('wildlife-sightings-label:inat:low'),
|
||||
'moveEnd admits separated names',
|
||||
);
|
||||
});
|
||||
|
||||
test('selection remains published while a new sample or camera pass replaces rendered lookup rows', () => {
|
||||
const f = fixture();
|
||||
f.selection.selectAndFocus(row.stableId);
|
||||
f.state.rowById.clear();
|
||||
f.state.renderedRows = [];
|
||||
f.selection.publishSelected();
|
||||
f.selection.notify();
|
||||
assert.equal(f.seen.at(-1).value, row);
|
||||
assert.equal(
|
||||
f.entries.get('wildlife-sightings-selected')[0].position,
|
||||
f.position,
|
||||
);
|
||||
assert.equal(
|
||||
f.flights.length,
|
||||
1,
|
||||
'refresh notifications never start another flight',
|
||||
);
|
||||
f.selection.clearAndRebuild();
|
||||
assert.equal(f.state.selectedId, null);
|
||||
assert.equal(f.state.selectedRow, null);
|
||||
assert.equal(f.seen.at(-1).value, null);
|
||||
assert.deepEqual(f.entries.get('wildlife-sightings-selected'), []);
|
||||
});
|
||||
|
||||
test('an obscured observation focuses only its existing privacy-safe anchor', () => {
|
||||
const obscured = { ...row, obscured: true, lat: 48.1, lon: 8.3 };
|
||||
const f = fixture(obscured);
|
||||
assert.equal(f.selection.selectAndFocus(obscured.stableId), true);
|
||||
assert.equal(f.flights[0].kind, 'sighting-area');
|
||||
assert.equal(f.flights[0].position, f.position);
|
||||
assert.equal(f.state.selectedRow, obscured);
|
||||
});
|
||||
|
||||
test('empty clicks and layer removal release the selected row and its overlay', () => {
|
||||
const f = fixture();
|
||||
f.selection.install();
|
||||
f.click();
|
||||
f.click(null);
|
||||
assert.equal(f.state.selectedRow, null);
|
||||
assert.equal(f.state.selectedId, null);
|
||||
f.click();
|
||||
f.state.enabled = false;
|
||||
f.selection.remove();
|
||||
assert.equal(f.state.selectedRow, null);
|
||||
assert.equal(f.state.selectedId, null);
|
||||
assert.equal(f.seen.at(-1).value, null);
|
||||
assert.equal(f.entries.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,663 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
import { mergeSightings } from './records.js';
|
||||
import { wildlifeMeasure } from './performance.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,
|
||||
wildlifeGridCellId,
|
||||
} from './gridViewport.js';
|
||||
|
||||
// Leave room for projected observations while bounding malformed proxy replies.
|
||||
const SNAPSHOT_MAX_BYTES = 2 * 1024 * 1024;
|
||||
// The proxy owns upstream pacing; observation requests precede these workers.
|
||||
export const WILDLIFE_GRID_CONCURRENCY = 4;
|
||||
export const WILDLIFE_GRID_CACHE_MAX = 400;
|
||||
export const WILDLIFE_GRID_TTL_MS = 15 * 60_000;
|
||||
// Match the proxy's one-hour stale allowance. Keep the original data time so
|
||||
// repeated refresh failures never extend a tile's usable life.
|
||||
const GRID_STALE_MAX_MS = 3_600_000;
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
function validTile(tile) {
|
||||
return (
|
||||
!!tile &&
|
||||
Number.isInteger(tile.z) &&
|
||||
tile.z >= 0 &&
|
||||
tile.z <= WILDLIFE_GRID_MAX_ZOOM &&
|
||||
Number.isInteger(tile.x) &&
|
||||
Number.isInteger(tile.y) &&
|
||||
tile.x >= 0 &&
|
||||
tile.y >= 0 &&
|
||||
tile.x < 2 ** tile.z &&
|
||||
tile.y < 2 ** tile.z
|
||||
);
|
||||
}
|
||||
|
||||
function validProjectedGrid(payload) {
|
||||
return (
|
||||
Number.isFinite(payload?.fetchedAt) &&
|
||||
Number.isSafeInteger(payload.totalResults) &&
|
||||
payload.totalResults >= 0 &&
|
||||
(payload.stale === undefined || typeof payload.stale === 'boolean') &&
|
||||
Array.isArray(payload.cells) &&
|
||||
payload.cells.length <= 4096 &&
|
||||
payload.cells.every(
|
||||
(cell) =>
|
||||
cell &&
|
||||
Object.keys(cell).sort().join(',') === 'cellCount,id,lat,lon' &&
|
||||
/^grid:\d+\/\d+\/\d+:\d+:\d+$/.test(cell.id) &&
|
||||
Number.isSafeInteger(cell.cellCount) &&
|
||||
cell.cellCount > 0 &&
|
||||
Number.isFinite(cell.lat) &&
|
||||
Math.abs(cell.lat) <= 90 &&
|
||||
Number.isFinite(cell.lon) &&
|
||||
Math.abs(cell.lon) <= 180 &&
|
||||
wildlifeGridCellId(
|
||||
cell.lat,
|
||||
cell.lon,
|
||||
Number(cell.id.split(':')[1].split('/')[0]),
|
||||
) === cell.id,
|
||||
) &&
|
||||
new Set(payload.cells.map((cell) => cell.id)).size ===
|
||||
payload.cells.length &&
|
||||
payload.cells.reduce((sum, cell) => sum + cell.cellCount, 0) ===
|
||||
payload.totalResults
|
||||
);
|
||||
}
|
||||
|
||||
function retryAfterSeconds(value, nowMs) {
|
||||
if (typeof value !== 'string' || !value.trim()) return null;
|
||||
const text = value.trim();
|
||||
if (/^\d+(?:\.\d+)?$/.test(text)) {
|
||||
const seconds = Number(text);
|
||||
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.min(
|
||||
WILDLIFE_RETRY_AFTER_MAX_SEC,
|
||||
Math.max(0, Math.ceil((deadline - nowMs) / 1000)),
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
function validProjectedRows(rows, origin) {
|
||||
return (
|
||||
Array.isArray(rows) &&
|
||||
rows.every(
|
||||
(row) =>
|
||||
row &&
|
||||
typeof row.stableId === 'string' &&
|
||||
row.stableId.startsWith(
|
||||
origin === 'inaturalist' ? 'inat:' : 'acartia:',
|
||||
) &&
|
||||
row.origin === origin &&
|
||||
Number.isFinite(row.lat) &&
|
||||
Math.abs(row.lat) <= 90 &&
|
||||
Number.isFinite(row.lon) &&
|
||||
Math.abs(row.lon) <= 180,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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
|
||||
* report the same geographic cell, so source ids remain the dedupe key. */
|
||||
export function combineWildlifeGridSnapshots(snapshots, error = null) {
|
||||
const cells = [
|
||||
...new Map(
|
||||
snapshots
|
||||
.flatMap((snapshot) => snapshot.cells)
|
||||
.map((cell) => [cell.id, cell]),
|
||||
).values(),
|
||||
];
|
||||
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),
|
||||
...wildlifeFailures(error)
|
||||
.filter(wildlifeFailureRetryable)
|
||||
.map((failure) => failure.retryAfterSec),
|
||||
].filter(Number.isFinite);
|
||||
const stale = snapshots.some((snapshot) => snapshot.stale === true);
|
||||
return {
|
||||
cells,
|
||||
totalResults,
|
||||
fetchedAt: Math.min(...snapshots.map((snapshot) => snapshot.fetchedAt)),
|
||||
stale,
|
||||
...(stale
|
||||
? {
|
||||
upstreamStatus: Math.max(
|
||||
0,
|
||||
...snapshots.map((snapshot) => snapshot.upstreamStatus || 0),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(error ? { error } : {}),
|
||||
...(retryAfter.length ? { retryAfterSec: Math.max(...retryAfter) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Request both provider proxies when coverage calls for it. API-specific
|
||||
* parameters (Cetacea, photo requirement, observation window) belong to the proxy. */
|
||||
export function createWildlifeSightingsSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
const gridCache = new Map();
|
||||
const gridInFlight = new Map();
|
||||
async function readProxy(url, provider, signal, { grid = false } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(url, { signal });
|
||||
signal?.throwIfAborted();
|
||||
const retryAfterSec = retryAfterSeconds(
|
||||
response.headers?.get?.('Retry-After'),
|
||||
now(),
|
||||
);
|
||||
const upstreamStatus =
|
||||
Number(response.headers?.get?.('X-Upstream-Status')) || 0;
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel?.();
|
||||
signal?.throwIfAborted();
|
||||
const error = new Error(
|
||||
provider === 'iNaturalist' && upstreamStatus === 429
|
||||
? 'iNaturalist rate limited'
|
||||
: `${provider} HTTP ${response.status}`,
|
||||
);
|
||||
error.status = response.status;
|
||||
error.upstreamStatus = upstreamStatus;
|
||||
if (retryAfterSec !== null) error.retryAfterSec = retryAfterSec;
|
||||
throw error;
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
payload = await readResponseJsonCapped(
|
||||
response,
|
||||
SNAPSHOT_MAX_BYTES,
|
||||
signal,
|
||||
);
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
throw new Error(`Malformed ${provider} response`, { cause: error });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === 'object' &&
|
||||
(payload.stale === true ||
|
||||
response.headers?.get?.('X-Data-Stale') === 'true')
|
||||
) {
|
||||
payload.stale = true;
|
||||
if (retryAfterSec !== null) payload.retryAfterSec = retryAfterSec;
|
||||
payload.upstreamStatus = upstreamStatus;
|
||||
}
|
||||
if (grid) {
|
||||
if (!wildlifeMeasure('tile-decode', () => validProjectedGrid(payload)))
|
||||
throw new Error('Malformed iNaturalist grid response');
|
||||
return payload;
|
||||
}
|
||||
const origin = provider === 'iNaturalist' ? 'inaturalist' : 'acartia';
|
||||
if (
|
||||
!validProjectedRows(payload?.rows, origin) ||
|
||||
(origin === 'inaturalist' &&
|
||||
(!Number.isSafeInteger(payload.totalResults) ||
|
||||
payload.totalResults < 0))
|
||||
) {
|
||||
throw new Error(`Malformed ${provider} response`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function readGridTile(key, url, metadata, signal) {
|
||||
signal?.throwIfAborted();
|
||||
let pending = gridInFlight.get(key);
|
||||
if (!pending || pending.controller.signal.aborted) {
|
||||
const controller = new AbortController();
|
||||
pending = { controller, consumers: new Set(), promise: null };
|
||||
const entry = pending;
|
||||
pending.promise = readProxy(url, 'iNaturalist', controller.signal, {
|
||||
grid: true,
|
||||
})
|
||||
.then((value) => {
|
||||
gridCache.delete(key);
|
||||
gridCache.set(key, {
|
||||
value,
|
||||
savedAt: value.stale ? Math.min(now(), value.fetchedAt) : now(),
|
||||
...metadata,
|
||||
});
|
||||
while (gridCache.size > WILDLIFE_GRID_CACHE_MAX)
|
||||
gridCache.delete(gridCache.keys().next().value);
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
if (gridInFlight.get(key) === entry) gridInFlight.delete(key);
|
||||
});
|
||||
gridInFlight.set(key, pending);
|
||||
}
|
||||
const consumer = {};
|
||||
pending.consumers.add(consumer);
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = (aborted = false) => {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
pending.consumers.delete(consumer);
|
||||
if (aborted && !pending.consumers.size) pending.controller.abort();
|
||||
};
|
||||
const abort = () => {
|
||||
finish(true);
|
||||
reject(signal.reason);
|
||||
};
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
pending.promise.then(
|
||||
(value) => {
|
||||
finish();
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
finish();
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Publish cached tiles immediately and refresh their occupied cells. */
|
||||
async function getGridSnapshot({
|
||||
tiles,
|
||||
confirmed = false,
|
||||
taxa = 'all',
|
||||
signal,
|
||||
onTile,
|
||||
} = {}) {
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
!Array.isArray(tiles) ||
|
||||
!tiles.length ||
|
||||
tiles.length > WILDLIFE_GRID_TILE_CAP ||
|
||||
!tiles.every(validTile)
|
||||
)
|
||||
throw new TypeError('Wildlife grid requires visible tiles');
|
||||
const preset = TAXA_PRESETS.some((item) => item.value === taxa)
|
||||
? taxa
|
||||
: 'all';
|
||||
const unique = [
|
||||
...new Map(
|
||||
tiles.map((tile) => [`${tile.z}/${tile.x}/${tile.y}`, tile]),
|
||||
).values(),
|
||||
].sort((a, b) => a.z - b.z || a.x - b.x || a.y - b.y);
|
||||
const windowKey = Math.floor(
|
||||
(now() - (preset === 'whales' ? 168 : 48) * HOUR_MS) / HOUR_MS,
|
||||
);
|
||||
const snapshots = new Array(unique.length);
|
||||
const failures = new Array(unique.length);
|
||||
const usable = (entry) => {
|
||||
const age = now() - entry.value.fetchedAt;
|
||||
return age >= 0 && age <= GRID_STALE_MAX_MS;
|
||||
};
|
||||
const touch = (key, entry) => {
|
||||
gridCache.delete(key);
|
||||
gridCache.set(key, entry);
|
||||
};
|
||||
function cachedFallback(tile, tileKey) {
|
||||
let exact = null;
|
||||
const adjacent = [];
|
||||
for (const [key, entry] of gridCache) {
|
||||
if (!usable(entry)) continue;
|
||||
if (key.startsWith(tileKey)) {
|
||||
if (!exact || entry.savedAt > exact.savedAt) exact = entry;
|
||||
} else if (
|
||||
entry.preset === preset &&
|
||||
entry.confirmed === confirmed &&
|
||||
Math.abs(entry.tile.z - tile.z) === 1 &&
|
||||
(entry.tile.z < tile.z
|
||||
? entry.tile.x === Math.floor(tile.x / 2) &&
|
||||
entry.tile.y === Math.floor(tile.y / 2)
|
||||
: Math.floor(entry.tile.x / 2) === tile.x &&
|
||||
Math.floor(entry.tile.y / 2) === tile.y)
|
||||
) {
|
||||
adjacent.push(entry);
|
||||
}
|
||||
}
|
||||
if (exact) return { ...exact.value, stale: true };
|
||||
if (!adjacent.length) return null;
|
||||
// Preserve cached geographic centres and counts while exact tiles load.
|
||||
const newest = [
|
||||
...new Map(
|
||||
adjacent
|
||||
.sort((a, b) => a.savedAt - b.savedAt)
|
||||
.map((entry) => [
|
||||
`${entry.tile.z}/${entry.tile.x}/${entry.tile.y}`,
|
||||
entry,
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
const parent = newest.find((entry) => entry.tile.z < tile.z);
|
||||
const values = parent
|
||||
? [parent.value]
|
||||
: newest.map((entry) => entry.value);
|
||||
const value = combineWildlifeGridSnapshots(values);
|
||||
value.cells = value.cells.filter((cell) =>
|
||||
wildlifeGridCellId(cell.lat, cell.lon, tile.z)?.startsWith(
|
||||
`grid:${tile.z}/${tile.x}/${tile.y}:`,
|
||||
),
|
||||
);
|
||||
value.totalResults = value.cells.reduce(
|
||||
(sum, cell) => sum + cell.cellCount,
|
||||
0,
|
||||
);
|
||||
return { ...value, stale: true };
|
||||
}
|
||||
function publish(index, value) {
|
||||
signal?.throwIfAborted();
|
||||
snapshots[index] = value;
|
||||
onTile?.(value, { tile: unique[index] });
|
||||
}
|
||||
const requests = [];
|
||||
// Publish all cached coverage before slow misses can occupy the workers.
|
||||
for (let index = 0; index < unique.length; index += 1) {
|
||||
const tile = unique[index];
|
||||
const tileKey = `${tile.z}/${tile.x}/${tile.y}:${preset}:${confirmed ? 'research' : 'all'}:`;
|
||||
const key = `${tileKey}${windowKey}`;
|
||||
const cached = gridCache.get(key);
|
||||
const age = cached ? now() - cached.savedAt : Infinity;
|
||||
if (
|
||||
age >= 0 &&
|
||||
age < WILDLIFE_GRID_TTL_MS &&
|
||||
!cached.value.stale &&
|
||||
usable(cached)
|
||||
) {
|
||||
touch(key, cached);
|
||||
publish(index, cached.value);
|
||||
continue;
|
||||
}
|
||||
const fallback = cachedFallback(tile, tileKey);
|
||||
if (fallback) publish(index, fallback);
|
||||
requests.push({ index, tile, key });
|
||||
}
|
||||
let cursor = 0;
|
||||
async function worker() {
|
||||
while (cursor < requests.length) {
|
||||
signal?.throwIfAborted();
|
||||
const { index, tile, key } = requests[cursor++];
|
||||
const query = new URLSearchParams({
|
||||
z: String(tile.z),
|
||||
x: String(tile.x),
|
||||
y: String(tile.y),
|
||||
taxa: preset,
|
||||
});
|
||||
if (confirmed) query.set('quality', 'research');
|
||||
try {
|
||||
let value = await readGridTile(
|
||||
key,
|
||||
`/api/inaturalist/grid?${query}`,
|
||||
{ tile, preset, confirmed },
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
publish(index, value);
|
||||
if (value.stale && !value.upstreamStatus) {
|
||||
// A server SWR response paints immediately. Join its existing
|
||||
// refresh so this same view receives the new cells when ready.
|
||||
query.set('refresh', 'wait');
|
||||
value = await readGridTile(
|
||||
key,
|
||||
`/api/inaturalist/grid?${query}`,
|
||||
{ tile, preset, confirmed },
|
||||
signal,
|
||||
);
|
||||
publish(index, value);
|
||||
}
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
failures[index] = error;
|
||||
// Other tiles can already be cached or in flight. Keep their
|
||||
// progressive coverage even if one upstream request fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(WILDLIFE_GRID_CONCURRENCY, requests.length) },
|
||||
worker,
|
||||
),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
const failure = preferredWildlifeFailure(failures);
|
||||
if (!snapshots.some(Boolean) && failure) throw failure;
|
||||
return combineWildlifeGridSnapshots(snapshots.filter(Boolean), failure);
|
||||
}
|
||||
|
||||
/** Merge recent provider rows with source availability and cache age. */
|
||||
async function getSnapshotDetail({
|
||||
signal,
|
||||
box = null,
|
||||
boxes = null,
|
||||
scope = 'view',
|
||||
confirmed = false,
|
||||
taxa = 'all',
|
||||
} = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const preset = TAXA_PRESETS.some((item) => item.value === taxa)
|
||||
? taxa
|
||||
: 'all';
|
||||
const requestBoxes =
|
||||
scope === 'global'
|
||||
? [null]
|
||||
: Array.isArray(boxes)
|
||||
? boxes
|
||||
: box
|
||||
? [box]
|
||||
: [];
|
||||
if (!requestBoxes.length)
|
||||
throw new TypeError('Wildlife sightings require a view box');
|
||||
const useAcartia =
|
||||
scope !== 'global' &&
|
||||
['all', 'Mammalia', 'whales'].includes(preset) &&
|
||||
requestBoxes.some((bounds) => boxIntersects(bounds, ACARTIA_GUARD_BOX));
|
||||
|
||||
// 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',
|
||||
taxa: preset,
|
||||
});
|
||||
if (confirmed) params.set('quality', 'research');
|
||||
if (scope !== 'global') {
|
||||
for (const key of ['south', 'west', 'north', 'east'])
|
||||
params.set(key, String(bounds[key]));
|
||||
}
|
||||
return readProxy(
|
||||
`/api/inaturalist/observations?${params}`,
|
||||
'iNaturalist',
|
||||
signal,
|
||||
);
|
||||
}),
|
||||
);
|
||||
const acartiaRequest = useAcartia
|
||||
? readProxy('/api/acartia/sightings', 'Acartia', signal)
|
||||
: Promise.resolve(null);
|
||||
const [inatResult, acartiaResult] = await Promise.allSettled([
|
||||
inatRequest,
|
||||
acartiaRequest,
|
||||
]);
|
||||
signal?.throwIfAborted();
|
||||
const inatSnapshots = [];
|
||||
const failedBoxes = [];
|
||||
const inatErrors = [];
|
||||
inatResult.value.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') inatSnapshots.push(result.value);
|
||||
else {
|
||||
failedBoxes.push(requestBoxes[index]);
|
||||
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 =
|
||||
useAcartia && acartiaResult.status === 'rejected'
|
||||
? acartiaResult.reason
|
||||
: null;
|
||||
if (
|
||||
!inatSnapshots.length &&
|
||||
(!useAcartia || acartiaResult.status !== 'fulfilled')
|
||||
)
|
||||
throw preferredWildlifeFailure([inatError, acartiaError]);
|
||||
const inatRows = inatSnapshots.flatMap((snapshot) => snapshot.rows);
|
||||
const acartiaSnapshot =
|
||||
acartiaResult.status === 'fulfilled' ? acartiaResult.value : null;
|
||||
const acartiaRows = acartiaSnapshot?.rows || [];
|
||||
const rows = mergeSightings(inatRows, acartiaRows, {
|
||||
nowMs: now(),
|
||||
taxa: preset,
|
||||
confirmed,
|
||||
});
|
||||
const inatDates = inatSnapshots
|
||||
.map((snapshot) => snapshot.fetchedAt)
|
||||
.filter(Number.isFinite);
|
||||
const inatFetchedAt = inatDates.length ? Math.min(...inatDates) : null;
|
||||
const inatStale = inatSnapshots.some((snapshot) => snapshot.stale === true);
|
||||
const acartiaStale = acartiaSnapshot?.stale === true;
|
||||
const drawnAcartia = rows.filter((row) => row.origin === 'acartia').length;
|
||||
const dates = [
|
||||
inatFetchedAt,
|
||||
drawnAcartia ? acartiaSnapshot?.fetchedAt : null,
|
||||
].filter(Number.isFinite);
|
||||
const retryAfter = [
|
||||
...inatSnapshots.map((snapshot) => snapshot.retryAfterSec),
|
||||
drawnAcartia ? acartiaSnapshot?.retryAfterSec : null,
|
||||
].filter(Number.isFinite);
|
||||
return {
|
||||
rows,
|
||||
stale: inatStale || (drawnAcartia > 0 && acartiaStale),
|
||||
...(inatStale || acartiaStale
|
||||
? {
|
||||
upstreamStatus: Math.max(
|
||||
0,
|
||||
...inatSnapshots.map((snapshot) => snapshot.upstreamStatus || 0),
|
||||
acartiaSnapshot?.upstreamStatus || 0,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
fetchedAt: dates.length ? Math.min(...dates) : null,
|
||||
totalResults:
|
||||
inatSnapshots.reduce(
|
||||
(total, snapshot) => total + snapshot.totalResults,
|
||||
0,
|
||||
) + drawnAcartia,
|
||||
scope: scope === 'global' ? 'global' : 'view',
|
||||
partial: inatSnapshots.some((snapshot) => snapshot.partial === true),
|
||||
...(retryAfter.length ? { retryAfterSec: Math.max(...retryAfter) } : {}),
|
||||
sources: {
|
||||
inaturalist: {
|
||||
ok: !inatError,
|
||||
stale: inatStale,
|
||||
fetchedAt: inatFetchedAt,
|
||||
count: rows.filter((row) => row.origin === 'inaturalist').length,
|
||||
...(inatError ? { error: inatError, failedBoxes } : {}),
|
||||
},
|
||||
acartia: {
|
||||
ok: !acartiaError,
|
||||
stale: acartiaStale,
|
||||
fetchedAt: Number.isFinite(acartiaSnapshot?.fetchedAt)
|
||||
? acartiaSnapshot.fetchedAt
|
||||
: null,
|
||||
count: drawnAcartia,
|
||||
...(acartiaError ? { error: acartiaError } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
/** Return recent, privacy-projected rows without source status metadata. */
|
||||
async getSnapshot(options = {}) {
|
||||
return (await getSnapshotDetail(options)).rows;
|
||||
},
|
||||
getSnapshotDetail,
|
||||
getGridSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,889 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createWildlifeSightingsSource,
|
||||
WILDLIFE_GRID_TTL_MS,
|
||||
} from './source.js';
|
||||
import { wildlifeGridCellId } from './gridViewport.js';
|
||||
|
||||
const now = Date.UTC(2026, 10, 4, 18);
|
||||
const seattle = { south: 47.5, west: -122.5, north: 47.7, east: -122.2 };
|
||||
const outside = { south: 10, west: 10, north: 12, east: 12 };
|
||||
const inat = {
|
||||
stableId: 'inat:123',
|
||||
origin: 'inaturalist',
|
||||
lat: 47.6,
|
||||
lon: -122.3,
|
||||
observedAt: now - 3_600_000,
|
||||
observedOn: '2026-11-04',
|
||||
};
|
||||
const acartia = {
|
||||
stableId: 'acartia:abc',
|
||||
origin: 'acartia',
|
||||
lat: 48.12,
|
||||
lon: -122.54,
|
||||
observedAt: now - 3_600_000,
|
||||
cetacean: true,
|
||||
quality: 'research',
|
||||
provider: { name: 'Provider', entity: null },
|
||||
};
|
||||
const inatEnvelope = (extra = {}) => ({
|
||||
fetchedAt: now,
|
||||
rows: [inat],
|
||||
scope: 'view',
|
||||
totalResults: 1,
|
||||
...extra,
|
||||
});
|
||||
const acartiaEnvelope = (extra = {}) => ({
|
||||
fetchedAt: now,
|
||||
rows: [acartia],
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('construction performs no acquisition', () => {
|
||||
createWildlifeSightingsSource({
|
||||
fetchImpl: () => assert.fail('construction fetched'),
|
||||
});
|
||||
});
|
||||
|
||||
test('source sends proxy-only scope, box, taxa and confirmed parameters for all presets', async () => {
|
||||
for (const taxa of [
|
||||
'all',
|
||||
'Aves',
|
||||
'Mammalia',
|
||||
'whales',
|
||||
'Insecta',
|
||||
'Plantae',
|
||||
]) {
|
||||
const urls = [];
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(new URL(url, 'https://example.test'));
|
||||
return Response.json(inatEnvelope());
|
||||
},
|
||||
});
|
||||
await source.getSnapshotDetail({ box: outside, taxa, confirmed: true });
|
||||
assert.equal(urls.length, 1);
|
||||
assert.equal(urls[0].pathname, '/api/inaturalist/observations');
|
||||
assert.deepEqual(Object.fromEntries(urls[0].searchParams), {
|
||||
scope: 'view',
|
||||
taxa,
|
||||
quality: 'research',
|
||||
south: '10',
|
||||
west: '10',
|
||||
north: '12',
|
||||
east: '12',
|
||||
});
|
||||
// taxon_id, d1 (observed date), photos and quality_grade are upstream
|
||||
// choices owned and tested by the server, never browser query controls.
|
||||
}
|
||||
});
|
||||
|
||||
test('confirmed false omits quality, and global sample omits bbox and Acartia', async () => {
|
||||
const urls = [];
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(url);
|
||||
return Response.json(inatEnvelope({ scope: 'global' }));
|
||||
},
|
||||
});
|
||||
const result = await source.getSnapshotDetail({
|
||||
scope: 'global',
|
||||
taxa: 'whales',
|
||||
confirmed: false,
|
||||
box: seattle,
|
||||
});
|
||||
assert.equal(result.scope, 'global');
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(new URL(urls[0], 'https://example.test').searchParams),
|
||||
{ scope: 'global', taxa: 'whales' },
|
||||
);
|
||||
assert.equal(urls.length, 1);
|
||||
});
|
||||
|
||||
test('Acartia loads only inside coverage for the three mammal-inclusive presets', async () => {
|
||||
for (const box of [seattle, outside])
|
||||
for (const taxa of [
|
||||
'all',
|
||||
'Aves',
|
||||
'Mammalia',
|
||||
'whales',
|
||||
'Insecta',
|
||||
'Plantae',
|
||||
]) {
|
||||
const urls = [];
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(url);
|
||||
return Response.json(
|
||||
url.startsWith('/api/acartia') ? acartiaEnvelope() : inatEnvelope(),
|
||||
);
|
||||
},
|
||||
});
|
||||
const result = await source.getSnapshotDetail({ box, taxa });
|
||||
const expected =
|
||||
box === seattle && ['all', 'Mammalia', 'whales'].includes(taxa);
|
||||
assert.equal(
|
||||
urls.some((url) => url === '/api/acartia/sightings'),
|
||||
expected,
|
||||
);
|
||||
assert.equal(result.rows.length, expected ? 2 : 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('Acartia failure preserves iNat rows with an explicit failed source', async () => {
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) =>
|
||||
url.startsWith('/api/acartia')
|
||||
? new Response('', { status: 502 })
|
||||
: Response.json(inatEnvelope()),
|
||||
});
|
||||
const result = await source.getSnapshotDetail({ box: seattle });
|
||||
assert.deepEqual(result.rows, [inat]);
|
||||
assert.equal(result.sources.acartia.ok, false);
|
||||
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({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) =>
|
||||
Response.json(
|
||||
url.startsWith('/api/acartia')
|
||||
? acartiaEnvelope()
|
||||
: inatEnvelope({
|
||||
fetchedAt,
|
||||
stale: true,
|
||||
partial: true,
|
||||
totalResults: 400,
|
||||
}),
|
||||
),
|
||||
});
|
||||
const result = await source.getSnapshotDetail({ box: seattle });
|
||||
assert.equal(result.stale, true);
|
||||
assert.equal(result.fetchedAt, fetchedAt);
|
||||
assert.equal(result.partial, true);
|
||||
assert.equal(result.totalResults, 401);
|
||||
assert.deepEqual(result.sources.inaturalist, {
|
||||
ok: true,
|
||||
stale: true,
|
||||
fetchedAt,
|
||||
count: 1,
|
||||
});
|
||||
assert.equal(result.sources.acartia.count, 1);
|
||||
});
|
||||
|
||||
test('antimeridian boxes fetch once each and merge duplicate boundary rows', async () => {
|
||||
const urls = [];
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(url);
|
||||
return Response.json(inatEnvelope());
|
||||
},
|
||||
});
|
||||
const boxes = [
|
||||
{ south: 10, west: 179, north: 12, east: 180 },
|
||||
{ south: 10, west: -180, north: 12, east: -178 },
|
||||
];
|
||||
const result = await source.getSnapshotDetail({ boxes });
|
||||
assert.equal(urls.length, 2);
|
||||
assert.equal(result.rows.length, 1);
|
||||
assert.equal(result.totalResults, 2);
|
||||
});
|
||||
|
||||
test('iNat non-OK responses carry stable status messages and status values', async () => {
|
||||
for (const status of [429, 502]) {
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: async () =>
|
||||
new Response('', {
|
||||
status,
|
||||
headers: { 'X-Upstream-Status': String(status) },
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot({ box: outside }), (error) => {
|
||||
assert.equal(
|
||||
error.message,
|
||||
status === 429 ? 'iNaturalist rate limited' : 'iNaturalist HTTP 502',
|
||||
);
|
||||
assert.equal(error.status, status);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Retry-After delta seconds and HTTP dates use the injected source clock', async () => {
|
||||
for (const [header, seconds] of [
|
||||
['3600', 3600],
|
||||
[new Date(now + 12 * 3_600_000).toUTCString(), 43_200],
|
||||
[new Date(now - 1000).toUTCString(), 0],
|
||||
['0', 0],
|
||||
['not a deadline', undefined],
|
||||
['-10', undefined],
|
||||
['', undefined],
|
||||
]) {
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async () =>
|
||||
new Response('', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': header },
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot({ box: outside }), (error) => {
|
||||
assert.equal(error.status, 429);
|
||||
assert.equal(error.retryAfterSec, seconds, header);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed envelopes and invalid rows reject instead of becoming empty', async () => {
|
||||
for (const payload of [
|
||||
{},
|
||||
{ rows: {} },
|
||||
{ rows: [], totalResults: null },
|
||||
{ rows: [{}], totalResults: 1 },
|
||||
{ results: [], total_results: 0 },
|
||||
]) {
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: async () => Response.json(payload),
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getSnapshot({ box: outside }),
|
||||
/Malformed iNaturalist response/,
|
||||
);
|
||||
}
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: async () => new Response('{bad'),
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getSnapshot({ box: outside }),
|
||||
/Malformed iNaturalist response/,
|
||||
);
|
||||
});
|
||||
|
||||
test('well-formed empty projected snapshot is legitimate and getSnapshot returns only rows', async () => {
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: async () =>
|
||||
Response.json(inatEnvelope({ rows: [], totalResults: 0 })),
|
||||
});
|
||||
assert.deepEqual(await source.getSnapshot({ box: outside }), []);
|
||||
});
|
||||
|
||||
test('abort is checked before acquisition and after a late fetch resolves', async () => {
|
||||
const early = new AbortController();
|
||||
early.abort();
|
||||
const idle = createWildlifeSightingsSource({
|
||||
fetchImpl: () => assert.fail('aborted fetch ran'),
|
||||
});
|
||||
await assert.rejects(
|
||||
idle.getSnapshot({ box: outside, signal: early.signal }),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
let resolve;
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
});
|
||||
const late = new AbortController();
|
||||
const request = source.getSnapshot({ box: outside, signal: late.signal });
|
||||
late.abort();
|
||||
resolve(Response.json(inatEnvelope()));
|
||||
await assert.rejects(request, { name: 'AbortError' });
|
||||
});
|
||||
|
||||
const gridCell = {
|
||||
id: 'grid:2/1/1:1:51',
|
||||
cellCount: 6,
|
||||
lat: 65.94,
|
||||
lon: -18.28125,
|
||||
};
|
||||
const gridEnvelope = (extra = {}) => ({
|
||||
cells: [gridCell],
|
||||
totalResults: 6,
|
||||
fetchedAt: now,
|
||||
...extra,
|
||||
});
|
||||
const gridTile = { z: 2, x: 1, y: 1 };
|
||||
|
||||
test('grid source sends visible XYZ/filter parameters only, dedupes tiles and keeps source untouched', async () => {
|
||||
const urls = [];
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url, options) => {
|
||||
urls.push(new URL(url, 'https://example.test'));
|
||||
assert.ok(options.signal instanceof AbortSignal);
|
||||
return Response.json(gridEnvelope());
|
||||
},
|
||||
});
|
||||
const result = await source.getGridSnapshot({
|
||||
tiles: [gridTile, gridTile],
|
||||
confirmed: true,
|
||||
taxa: 'Aves',
|
||||
});
|
||||
assert.equal(urls.length, 1);
|
||||
assert.equal(urls[0].pathname, '/api/inaturalist/grid');
|
||||
assert.deepEqual(Object.fromEntries(urls[0].searchParams), {
|
||||
z: '2',
|
||||
x: '1',
|
||||
y: '1',
|
||||
taxa: 'Aves',
|
||||
quality: 'research',
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
cells: [gridCell],
|
||||
totalResults: 6,
|
||||
fetchedAt: now,
|
||||
stale: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('grid source caches stable tile/filter/hour keys and combines neighbor edge cells once', async () => {
|
||||
let clock = now;
|
||||
let calls = 0;
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => clock,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return Response.json(gridEnvelope());
|
||||
},
|
||||
});
|
||||
const options = { tiles: [gridTile, { z: 2, x: 2, y: 1 }] };
|
||||
const result = await source.getGridSnapshot(options);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(
|
||||
result.cells.length,
|
||||
1,
|
||||
'same geographic cell can bleed into a neighboring hit tile',
|
||||
);
|
||||
assert.equal(result.totalResults, 6);
|
||||
await source.getGridSnapshot({ tiles: [...options.tiles].reverse() });
|
||||
assert.equal(calls, 2, 'ordering does not change stable keys');
|
||||
await source.getGridSnapshot({ ...options, confirmed: true });
|
||||
assert.equal(calls, 4, 'quality changes the key');
|
||||
clock += WILDLIFE_GRID_TTL_MS + 1;
|
||||
await source.getGridSnapshot(options);
|
||||
assert.equal(calls, 6, 'fifteen minute refresh cap');
|
||||
});
|
||||
|
||||
test('grid source preserves stale metadata and rejects invalid projected schema/privacy fields', async () => {
|
||||
const staleSource = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async () =>
|
||||
Response.json(gridEnvelope({ stale: true, fetchedAt: now - 1000 })),
|
||||
});
|
||||
const stale = await staleSource.getGridSnapshot({ tiles: [gridTile] });
|
||||
assert.equal(stale.stale, true);
|
||||
assert.equal(stale.fetchedAt, now - 1000);
|
||||
for (const payload of [
|
||||
{},
|
||||
gridEnvelope({ cells: {} }),
|
||||
gridEnvelope({ totalResults: -1 }),
|
||||
gridEnvelope({ totalResults: 5 }),
|
||||
gridEnvelope({ fetchedAt: null }),
|
||||
gridEnvelope({ cells: [{ ...gridCell, uuid: 'must-not-leave-proxy' }] }),
|
||||
gridEnvelope({ cells: [{ ...gridCell, iconic: 'Aves' }] }),
|
||||
gridEnvelope({ cells: [{ ...gridCell, lat: 91 }] }),
|
||||
gridEnvelope({ cells: [{ ...gridCell, id: 'grid:99/0/0:1:1' }] }),
|
||||
gridEnvelope({ cells: [{ ...gridCell, id: 'grid:2/1/1:3:51' }] }),
|
||||
gridEnvelope({ cells: [gridCell, gridCell], totalResults: 12 }),
|
||||
]) {
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: async () => Response.json(payload),
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getGridSnapshot({ tiles: [gridTile] }),
|
||||
/Malformed iNaturalist grid response/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('grid source validates request bounds before fetching and aborts superseded loads', async () => {
|
||||
const idle = createWildlifeSightingsSource({
|
||||
fetchImpl: () => assert.fail('invalid/aborted grid acquired'),
|
||||
});
|
||||
for (const tiles of [
|
||||
undefined,
|
||||
[],
|
||||
[{ z: 2, x: 4, y: 0 }],
|
||||
[{ z: -1, x: 0, y: 0 }],
|
||||
Array(13).fill(gridTile),
|
||||
]) {
|
||||
await assert.rejects(
|
||||
idle.getGridSnapshot({ tiles }),
|
||||
/requires visible tiles/,
|
||||
);
|
||||
}
|
||||
const early = new AbortController();
|
||||
early.abort();
|
||||
await assert.rejects(
|
||||
idle.getGridSnapshot({ tiles: [gridTile], signal: early.signal }),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
let resolve;
|
||||
let passedSignal;
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: (_url, options) => {
|
||||
passedSignal = options.signal;
|
||||
return new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
});
|
||||
const late = new AbortController();
|
||||
const loading = source.getGridSnapshot({
|
||||
tiles: [gridTile],
|
||||
signal: late.signal,
|
||||
});
|
||||
late.abort();
|
||||
assert.equal(passedSignal.aborted, true);
|
||||
resolve(Response.json(gridEnvelope()));
|
||||
await assert.rejects(loading, { name: 'AbortError' });
|
||||
});
|
||||
|
||||
test('grid worker concurrency is bounded and a failed tile preserves progressive siblings', async () => {
|
||||
const waiting = [];
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: (_url, options) =>
|
||||
new Promise((resolve, reject) => {
|
||||
waiting.push({ resolve, signal: options.signal });
|
||||
options.signal.addEventListener(
|
||||
'abort',
|
||||
() => reject(options.signal.reason),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
});
|
||||
const tiles = Array.from({ length: 6 }, (_, x) => ({ z: 3, x, y: 0 }));
|
||||
const loading = source.getGridSnapshot({ tiles });
|
||||
assert.equal(waiting.length, 4);
|
||||
waiting[0].resolve(new Response('', { status: 502 }));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
for (let i = 1; i < 4; i++) waiting[i].resolve(Response.json(gridEnvelope()));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
for (let i = 4; i < waiting.length; i++)
|
||||
waiting[i].resolve(Response.json(gridEnvelope()));
|
||||
const result = await loading;
|
||||
assert.equal(result.error.message, 'iNaturalist HTTP 502');
|
||||
assert.deepEqual(result.cells, [gridCell]);
|
||||
assert.ok(waiting.every((item) => !item.signal.aborted));
|
||||
});
|
||||
|
||||
test('failed grid refresh keeps the tile last good cells across its window-hour key, without extending stale life', async () => {
|
||||
let clock = now + 55 * 60_000;
|
||||
let fail = false;
|
||||
let calls = 0;
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => clock,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return fail
|
||||
? new Response('', { status: 502, headers: { 'Retry-After': '40' } })
|
||||
: Response.json(gridEnvelope({ fetchedAt: clock }));
|
||||
},
|
||||
});
|
||||
const good = await source.getGridSnapshot({
|
||||
tiles: [gridTile],
|
||||
taxa: 'whales',
|
||||
});
|
||||
const fetchedAt = clock;
|
||||
fail = true;
|
||||
clock += 300_001;
|
||||
const stale = await source.getGridSnapshot({
|
||||
tiles: [gridTile],
|
||||
taxa: 'whales',
|
||||
});
|
||||
assert.deepEqual(stale.cells, good.cells);
|
||||
assert.equal(stale.totalResults, good.totalResults);
|
||||
assert.equal(stale.stale, true);
|
||||
assert.equal(stale.fetchedAt, fetchedAt);
|
||||
assert.equal(stale.error.message, 'iNaturalist HTTP 502');
|
||||
assert.equal(stale.retryAfterSec, 40);
|
||||
assert.equal(calls, 2);
|
||||
await assert.rejects(
|
||||
source.getGridSnapshot({ tiles: [gridTile], taxa: 'Aves' }),
|
||||
/iNaturalist HTTP 502/,
|
||||
'a different filter cannot borrow the last tile',
|
||||
);
|
||||
clock = fetchedAt + 3_600_001;
|
||||
await assert.rejects(
|
||||
source.getGridSnapshot({ tiles: [gridTile], taxa: 'whales' }),
|
||||
/iNaturalist HTTP 502/,
|
||||
'failed refresh did not renew original data age',
|
||||
);
|
||||
});
|
||||
|
||||
test('a rejected grid request never rejects a parallel observations acquisition', async () => {
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) =>
|
||||
url.startsWith('/api/inaturalist/grid?')
|
||||
? new Response('', { status: 502 })
|
||||
: Response.json(inatEnvelope()),
|
||||
});
|
||||
const [grid, observations] = await Promise.allSettled([
|
||||
source.getGridSnapshot({ tiles: [gridTile] }),
|
||||
source.getSnapshotDetail({ box: outside }),
|
||||
]);
|
||||
assert.equal(grid.status, 'rejected');
|
||||
assert.equal(grid.reason.status, 502);
|
||||
assert.equal(observations.status, 'fulfilled');
|
||||
assert.deepEqual(observations.value.rows, [inat]);
|
||||
});
|
||||
|
||||
test('one failed cached tile retains its cells while neighboring tiles refresh successfully', async () => {
|
||||
let clock = now;
|
||||
let refreshing = false;
|
||||
const neighbor = {
|
||||
...gridCell,
|
||||
id: wildlifeGridCellId(30, 20, 2),
|
||||
lat: 30,
|
||||
lon: 20,
|
||||
};
|
||||
const signals = [];
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => clock,
|
||||
fetchImpl: async (url, { signal }) => {
|
||||
signals.push(signal);
|
||||
const isFailedTile =
|
||||
new URL(url, 'https://example.test').searchParams.get('x') === '1';
|
||||
if (refreshing && isFailedTile) return new Response('', { status: 502 });
|
||||
const cell = isFailedTile
|
||||
? gridCell
|
||||
: { ...neighbor, cellCount: refreshing ? 10 : 6 };
|
||||
return Response.json({
|
||||
cells: [cell],
|
||||
totalResults: cell.cellCount,
|
||||
fetchedAt: clock,
|
||||
});
|
||||
},
|
||||
});
|
||||
const options = { tiles: [gridTile, { z: 2, x: 2, y: 1 }] };
|
||||
await source.getGridSnapshot(options);
|
||||
refreshing = true;
|
||||
clock += WILDLIFE_GRID_TTL_MS + 1;
|
||||
const result = await source.getGridSnapshot(options);
|
||||
assert.equal(result.cells.length, 2);
|
||||
assert.equal(
|
||||
result.cells.find((cell) => cell.id === gridCell.id).cellCount,
|
||||
6,
|
||||
);
|
||||
assert.equal(
|
||||
result.cells.find((cell) => cell.id === neighbor.id).cellCount,
|
||||
10,
|
||||
);
|
||||
assert.equal(result.totalResults, 16);
|
||||
assert.equal(result.stale, true);
|
||||
assert.equal(result.error.message, 'iNaturalist HTTP 502');
|
||||
assert.ok(signals.every((signal) => !signal.aborted));
|
||||
});
|
||||
|
||||
test('stale successful proxy answers preserve Retry-After for observation and grid retry guidance', async () => {
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) =>
|
||||
Response.json(
|
||||
url.startsWith('/api/inaturalist/grid?')
|
||||
? gridEnvelope({ stale: true })
|
||||
: inatEnvelope({ stale: true }),
|
||||
{ headers: { 'X-Data-Stale': 'true', 'Retry-After': '75' } },
|
||||
),
|
||||
});
|
||||
const rows = await source.getSnapshotDetail({ box: outside });
|
||||
const grid = await source.getGridSnapshot({ tiles: [gridTile] });
|
||||
assert.equal(rows.stale, true);
|
||||
assert.equal(rows.retryAfterSec, 75);
|
||||
assert.equal(grid.stale, true);
|
||||
assert.equal(grid.retryAfterSec, 75);
|
||||
});
|
||||
|
||||
test('client stale tiles publish before revalidation finishes and retain the original data date', async () => {
|
||||
let clock = now;
|
||||
let refresh;
|
||||
let calls = 0;
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => clock,
|
||||
fetchImpl: () =>
|
||||
++calls === 1
|
||||
? Promise.resolve(Response.json(gridEnvelope()))
|
||||
: new Promise((resolve) => {
|
||||
refresh = resolve;
|
||||
}),
|
||||
});
|
||||
await source.getGridSnapshot({ tiles: [gridTile] });
|
||||
clock += WILDLIFE_GRID_TTL_MS + 1;
|
||||
const paints = [];
|
||||
const pending = source.getGridSnapshot({
|
||||
tiles: [gridTile],
|
||||
onTile: (tile) => paints.push(tile),
|
||||
});
|
||||
assert.equal(
|
||||
paints.length,
|
||||
1,
|
||||
'stale cache is synchronous, before the fetch settles',
|
||||
);
|
||||
assert.equal(paints[0].stale, true);
|
||||
assert.equal(paints[0].fetchedAt, now);
|
||||
refresh(
|
||||
Response.json(
|
||||
gridEnvelope({
|
||||
cells: [{ ...gridCell, cellCount: 9 }],
|
||||
totalResults: 9,
|
||||
fetchedAt: clock,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const result = await pending;
|
||||
assert.equal(paints.length, 2);
|
||||
assert.equal(paints[1].stale, undefined);
|
||||
assert.equal(result.totalResults, 9);
|
||||
});
|
||||
|
||||
test('cached coverage publishes before slow misses earlier in XYZ order fill all workers', async () => {
|
||||
const waiting = [];
|
||||
let cold = false;
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: (url) => {
|
||||
const x = Number(
|
||||
new URL(url, 'https://example.test').searchParams.get('x'),
|
||||
);
|
||||
const cell = {
|
||||
id: wildlifeGridCellId(83, x * 45 - 157.5, 3),
|
||||
lat: 83,
|
||||
lon: x * 45 - 157.5,
|
||||
cellCount: 6,
|
||||
};
|
||||
const value = { cells: [cell], totalResults: 6, fetchedAt: now };
|
||||
return cold
|
||||
? new Promise((resolve) =>
|
||||
waiting.push(() => resolve(Response.json(value))),
|
||||
)
|
||||
: Promise.resolve(Response.json(value));
|
||||
},
|
||||
});
|
||||
const tiles = Array.from({ length: 6 }, (_, x) => ({ z: 3, x, y: 0 }));
|
||||
await source.getGridSnapshot({ tiles: tiles.slice(4) });
|
||||
cold = true;
|
||||
const paints = [];
|
||||
const pending = source.getGridSnapshot({
|
||||
tiles,
|
||||
onTile: (value, metadata) => paints.push({ value, metadata }),
|
||||
});
|
||||
assert.deepEqual(
|
||||
paints.map(({ metadata }) => metadata.tile.x),
|
||||
[4, 5],
|
||||
'known cells paint synchronously even though the four earlier misses are unresolved',
|
||||
);
|
||||
assert.equal(waiting.length, 4);
|
||||
for (const finish of waiting) finish();
|
||||
const result = await pending;
|
||||
assert.equal(result.cells.length, 6);
|
||||
assert.equal(result.totalResults, 36);
|
||||
});
|
||||
|
||||
test('an adjacent zoom cache fills the new tile immediately while its exact cells load', async () => {
|
||||
let resolve;
|
||||
let calls = 0;
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: () =>
|
||||
++calls === 1
|
||||
? Promise.resolve(Response.json(gridEnvelope()))
|
||||
: new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
});
|
||||
await source.getGridSnapshot({ tiles: [gridTile] });
|
||||
const parent = { z: 1, x: 0, y: 0 };
|
||||
const paints = [];
|
||||
const pending = source.getGridSnapshot({
|
||||
tiles: [parent],
|
||||
onTile: (value) => paints.push(value),
|
||||
});
|
||||
assert.equal(paints.length, 1);
|
||||
assert.deepEqual(paints[0].cells, [gridCell]);
|
||||
assert.equal(calls, 2, 'the exact zoom still loads in the background');
|
||||
const cell = {
|
||||
...gridCell,
|
||||
id: wildlifeGridCellId(gridCell.lat, gridCell.lon, 1),
|
||||
};
|
||||
resolve(Response.json(gridEnvelope({ cells: [cell] })));
|
||||
await pending;
|
||||
assert.equal(paints.length, 2);
|
||||
assert.deepEqual(paints[1].cells, [cell]);
|
||||
});
|
||||
|
||||
test('concurrent client views share an in-flight tile without one consumer aborting the other', async () => {
|
||||
let resolve;
|
||||
let calls = 0;
|
||||
let signal;
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: (_url, options) => {
|
||||
calls++;
|
||||
signal = options.signal;
|
||||
return new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
});
|
||||
const first = new AbortController();
|
||||
const one = source.getGridSnapshot({
|
||||
tiles: [gridTile],
|
||||
signal: first.signal,
|
||||
});
|
||||
const two = source.getGridSnapshot({ tiles: [gridTile] });
|
||||
first.abort();
|
||||
await assert.rejects(one, { name: 'AbortError' });
|
||||
assert.equal(signal.aborted, false);
|
||||
resolve(Response.json(gridEnvelope()));
|
||||
assert.equal((await two).totalResults, 6);
|
||||
assert.equal(calls, 1);
|
||||
await source.getGridSnapshot({ tiles: [gridTile] });
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('local HTTP pacing errors never become iNaturalist rate-limited errors', async () => {
|
||||
for (const status of [429, 503]) {
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: async () =>
|
||||
new Response('', { status, headers: { 'Retry-After': '30' } }),
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getSnapshotDetail({ box: outside }),
|
||||
(error) => {
|
||||
assert.equal(error.upstreamStatus, 0);
|
||||
assert.doesNotMatch(error.message, /rate limited/i);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a server stale-while-revalidate answer joins its refresh and publishes fresh cells in the same view', async () => {
|
||||
const urls = [];
|
||||
let resolve;
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: (url) => {
|
||||
urls.push(url);
|
||||
return urls.length === 1
|
||||
? Promise.resolve(
|
||||
Response.json(
|
||||
gridEnvelope({
|
||||
stale: true,
|
||||
fetchedAt: now - WILDLIFE_GRID_TTL_MS - 1,
|
||||
}),
|
||||
),
|
||||
)
|
||||
: new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
});
|
||||
const paints = [];
|
||||
const pending = source.getGridSnapshot({
|
||||
tiles: [gridTile],
|
||||
onTile: (value) => paints.push(value),
|
||||
});
|
||||
await new Promise((done) => setImmediate(done));
|
||||
assert.equal(paints.length, 1);
|
||||
assert.equal(paints[0].stale, true);
|
||||
assert.equal(
|
||||
new URL(urls[1], 'https://example.test').searchParams.get('refresh'),
|
||||
'wait',
|
||||
);
|
||||
resolve(
|
||||
Response.json(
|
||||
gridEnvelope({
|
||||
cells: [{ ...gridCell, cellCount: 11 }],
|
||||
totalResults: 11,
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.equal((await pending).totalResults, 11);
|
||||
assert.equal(paints.length, 2);
|
||||
assert.equal(paints[1].stale, undefined);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { ICONIC_TINTS, WILDLIFE_SPRITE_PX } from './policy.js';
|
||||
|
||||
// Missing taxon symbols use related shapes from Material Symbols Outlined.
|
||||
export const WILDLIFE_BADGE_GLYPHS = Object.freeze({
|
||||
bird: 'raven',
|
||||
mammal: 'pets',
|
||||
reptile: 'egg_alt',
|
||||
amphibian: 'water_drop',
|
||||
fish: 'set_meal',
|
||||
insect: 'bug_report',
|
||||
arachnid: 'pest_control',
|
||||
plant: 'eco',
|
||||
fungus: 'umbrella',
|
||||
other: 'category',
|
||||
unknown: 'question_mark',
|
||||
});
|
||||
export const WILDLIFE_SPRITE_GROUPS = Object.freeze(
|
||||
Object.keys(WILDLIFE_BADGE_GLYPHS),
|
||||
);
|
||||
export const WILDLIFE_SPRITE_ICONIC = Object.freeze({
|
||||
bird: 'Aves',
|
||||
mammal: 'Mammalia',
|
||||
reptile: 'Reptilia',
|
||||
amphibian: 'Amphibia',
|
||||
fish: 'Actinopterygii',
|
||||
insect: 'Insecta',
|
||||
arachnid: 'Arachnida',
|
||||
plant: 'Plantae',
|
||||
fungus: 'Fungi',
|
||||
other: 'Animalia',
|
||||
unknown: 'Unknown',
|
||||
});
|
||||
const ICONIC_GROUPS = Object.freeze(
|
||||
Object.fromEntries(
|
||||
Object.entries(WILDLIFE_SPRITE_ICONIC).map(([group, iconic]) => [
|
||||
iconic,
|
||||
group,
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
/** Resolve a taxon or sprite group to one of the shared badge textures. */
|
||||
export function wildlifeSpriteGroup(groupOrIconic) {
|
||||
if (groupOrIconic === 'Mollusca') return 'other';
|
||||
return Object.hasOwn(WILDLIFE_SPRITE_ICONIC, groupOrIconic)
|
||||
? groupOrIconic
|
||||
: Object.hasOwn(ICONIC_GROUPS, groupOrIconic)
|
||||
? ICONIC_GROUPS[groupOrIconic]
|
||||
: 'unknown';
|
||||
}
|
||||
|
||||
export const WILDLIFE_BADGE_OUTLINE_PX = 1.5;
|
||||
const OUTLINE_COLOR = '#040c10';
|
||||
const FONT = '400 14px "Material Symbols Outlined"';
|
||||
const FONT_GLYPHS = Object.values(WILDLIFE_BADGE_GLYPHS).join(' ');
|
||||
const FALLBACKS = new Map(
|
||||
WILDLIFE_SPRITE_GROUPS.map((group) => [
|
||||
group,
|
||||
`data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 20 20"><circle cx="10" cy="10" r="9.1" fill="${ICONIC_TINTS[WILDLIFE_SPRITE_ICONIC[group]]}" stroke="${OUTLINE_COLOR}" stroke-width="${WILDLIFE_BADGE_OUTLINE_PX}"/></svg>`,
|
||||
)}`,
|
||||
]),
|
||||
);
|
||||
|
||||
/** One bounded set of canvas sprites per document, painted only after the
|
||||
* icon font is decoded. Until then the very same billboards use plain badges. */
|
||||
export function createWildlifeSpriteCache({
|
||||
documentRef = globalThis.document,
|
||||
} = {}) {
|
||||
const sprites = new Map();
|
||||
const listeners = new Set();
|
||||
let ready = false;
|
||||
let loading = null;
|
||||
|
||||
function paint(group) {
|
||||
const canvas = documentRef.createElement('canvas');
|
||||
canvas.width = canvas.height = WILDLIFE_SPRITE_PX * 2;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return FALLBACKS.get(group);
|
||||
ctx.scale(2, 2);
|
||||
ctx.beginPath();
|
||||
ctx.arc(10, 10, 9.1, 0, Math.PI * 2);
|
||||
ctx.fillStyle = ICONIC_TINTS[WILDLIFE_SPRITE_ICONIC[group]];
|
||||
ctx.fill();
|
||||
ctx.lineWidth = WILDLIFE_BADGE_OUTLINE_PX;
|
||||
ctx.strokeStyle = OUTLINE_COLOR;
|
||||
ctx.stroke();
|
||||
ctx.font = FONT;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillText(WILDLIFE_BADGE_GLYPHS[group], 10, 10, 16);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (loading) return loading;
|
||||
if (!documentRef?.fonts?.load) return Promise.resolve(false);
|
||||
loading = Promise.resolve()
|
||||
.then(() => documentRef.fonts.load(FONT, FONT_GLYPHS))
|
||||
.then((faces) => {
|
||||
if (!faces?.length) return false;
|
||||
// Paint once per group, never once per observation or refresh.
|
||||
for (const group of WILDLIFE_SPRITE_GROUPS)
|
||||
sprites.set(group, paint(group));
|
||||
ready = true;
|
||||
for (const listener of [...listeners]) listener();
|
||||
return true;
|
||||
})
|
||||
.catch(() => false);
|
||||
return loading;
|
||||
}
|
||||
|
||||
return {
|
||||
get(groupOrIconic) {
|
||||
const group = wildlifeSpriteGroup(groupOrIconic);
|
||||
return (ready ? sprites : FALLBACKS).get(group);
|
||||
},
|
||||
load,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
void load();
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const caches = new WeakMap();
|
||||
let headlessCache;
|
||||
/** Share the bounded sprite cache among layer instances in one document. */
|
||||
export function wildlifeSpriteCache(documentRef = globalThis.document) {
|
||||
if (!documentRef) return (headlessCache ??= createWildlifeSpriteCache());
|
||||
if (!caches.has(documentRef))
|
||||
caches.set(documentRef, createWildlifeSpriteCache({ documentRef }));
|
||||
return caches.get(documentRef);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import sharp from 'sharp';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import { ICONIC_TINTS, WILDLIFE_SPRITE_PX } from './policy.js';
|
||||
import {
|
||||
WILDLIFE_SPRITE_GROUPS,
|
||||
WILDLIFE_SPRITE_ICONIC,
|
||||
WILDLIFE_BADGE_GLYPHS,
|
||||
WILDLIFE_BADGE_OUTLINE_PX,
|
||||
createWildlifeSpriteCache,
|
||||
wildlifeSpriteCache,
|
||||
wildlifeSpriteGroup,
|
||||
} from './sprites.js';
|
||||
|
||||
const sprites = wildlifeSpriteCache();
|
||||
|
||||
test('every taxon group has an explicit Material Symbol and tinted circle fallback', () => {
|
||||
assert.equal(Object.isFrozen(WILDLIFE_BADGE_GLYPHS), true);
|
||||
assert.deepEqual(WILDLIFE_BADGE_GLYPHS, {
|
||||
bird: 'raven',
|
||||
mammal: 'pets',
|
||||
reptile: 'egg_alt',
|
||||
amphibian: 'water_drop',
|
||||
fish: 'set_meal',
|
||||
insect: 'bug_report',
|
||||
arachnid: 'pest_control',
|
||||
plant: 'eco',
|
||||
fungus: 'umbrella',
|
||||
other: 'category',
|
||||
unknown: 'question_mark',
|
||||
});
|
||||
const textures = new Set();
|
||||
for (const group of WILDLIFE_SPRITE_GROUPS) {
|
||||
const texture = sprites.get(group);
|
||||
textures.add(texture);
|
||||
assert.equal(wildlifeSpriteGroup(WILDLIFE_SPRITE_ICONIC[group]), group);
|
||||
assert.equal(sprites.get(WILDLIFE_SPRITE_ICONIC[group]), texture);
|
||||
assert.equal(sprites.get(group), texture);
|
||||
const svg = decodeURIComponent(texture.slice(texture.indexOf(',') + 1));
|
||||
assert.match(svg, /<circle /);
|
||||
assert.ok(
|
||||
svg.includes(`fill="${ICONIC_TINTS[WILDLIFE_SPRITE_ICONIC[group]]}"`),
|
||||
);
|
||||
assert.match(svg, /stroke="#040c10"/);
|
||||
assert.match(svg, /stroke-width="1.5"/);
|
||||
assert.doesNotMatch(svg, /<path|<text|<image|href=|<script|<foreignObject/);
|
||||
}
|
||||
assert.equal(textures.size, Object.keys(ICONIC_TINTS).length);
|
||||
});
|
||||
|
||||
test('unknown taxon keys share the slate fallback without allocating textures', () => {
|
||||
for (const value of [
|
||||
undefined,
|
||||
null,
|
||||
'Unknown',
|
||||
'Synthetic kingdom',
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'toString',
|
||||
]) {
|
||||
assert.equal(wildlifeSpriteGroup(value), 'unknown');
|
||||
assert.equal(sprites.get(value), sprites.get('unknown'));
|
||||
}
|
||||
assert.equal(sprites.get('Mollusca'), sprites.get('other'));
|
||||
});
|
||||
|
||||
test('canvas badges wait for the icon font, paint once and notify existing consumers', async () => {
|
||||
let resolveFont;
|
||||
let fontLoads = 0;
|
||||
const canvases = [];
|
||||
const documentRef = {
|
||||
fonts: {
|
||||
load(font, glyphs) {
|
||||
fontLoads += 1;
|
||||
assert.match(font, /Material Symbols Outlined/);
|
||||
for (const glyph of Object.values(WILDLIFE_BADGE_GLYPHS))
|
||||
assert.ok(glyphs.includes(glyph));
|
||||
return new Promise((resolve) => {
|
||||
resolveFont = resolve;
|
||||
});
|
||||
},
|
||||
},
|
||||
createElement(tag) {
|
||||
assert.equal(tag, 'canvas');
|
||||
const commands = [];
|
||||
const ctx = {
|
||||
scale(...args) {
|
||||
commands.push(['scale', ...args]);
|
||||
},
|
||||
beginPath() {},
|
||||
arc(...args) {
|
||||
commands.push(['arc', ...args]);
|
||||
},
|
||||
fill() {
|
||||
commands.push(['fill', this.fillStyle]);
|
||||
},
|
||||
stroke() {
|
||||
commands.push(['stroke', this.strokeStyle, this.lineWidth]);
|
||||
},
|
||||
fillText(...args) {
|
||||
commands.push(['text', this.fillStyle, this.font, ...args]);
|
||||
},
|
||||
};
|
||||
const canvas = { commands, getContext: () => ctx };
|
||||
canvases.push(canvas);
|
||||
return canvas;
|
||||
},
|
||||
};
|
||||
const cache = createWildlifeSpriteCache({ documentRef });
|
||||
const fallback = cache.get('Aves');
|
||||
let notifications = 0;
|
||||
const unsubscribe = cache.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
const loading = cache.load();
|
||||
assert.equal(cache.load(), loading);
|
||||
await Promise.resolve();
|
||||
assert.equal(fontLoads, 1);
|
||||
assert.equal(canvases.length, 0, 'no glyph is rasterized in a fallback font');
|
||||
assert.equal(cache.get('Aves'), fallback);
|
||||
resolveFont([{}]);
|
||||
assert.equal(await loading, true);
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(canvases.length, WILDLIFE_SPRITE_GROUPS.length);
|
||||
for (const group of WILDLIFE_SPRITE_GROUPS) {
|
||||
const canvas = cache.get(group);
|
||||
assert.equal(canvas, cache.get(WILDLIFE_SPRITE_ICONIC[group]));
|
||||
assert.equal(canvas.width, WILDLIFE_SPRITE_PX * 2);
|
||||
assert.equal(canvas.height, WILDLIFE_SPRITE_PX * 2);
|
||||
assert.deepEqual(
|
||||
canvas.commands.find(([op]) => op === 'fill'),
|
||||
['fill', ICONIC_TINTS[WILDLIFE_SPRITE_ICONIC[group]]],
|
||||
);
|
||||
assert.deepEqual(
|
||||
canvas.commands.find(([op]) => op === 'stroke'),
|
||||
['stroke', '#040c10', WILDLIFE_BADGE_OUTLINE_PX],
|
||||
);
|
||||
const glyph = canvas.commands.find(([op]) => op === 'text');
|
||||
assert.equal(glyph[1], '#ffffff');
|
||||
assert.match(glyph[2], /Material Symbols Outlined/);
|
||||
assert.equal(glyph[3], WILDLIFE_BADGE_GLYPHS[group]);
|
||||
}
|
||||
await cache.load();
|
||||
assert.equal(canvases.length, WILDLIFE_SPRITE_GROUPS.length);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test('unavailable font keeps plain badges and does not paint a ligature word', async () => {
|
||||
const cache = createWildlifeSpriteCache({
|
||||
documentRef: {
|
||||
fonts: { load: async () => [] },
|
||||
createElement() {
|
||||
assert.fail('no canvas before the icon font is ready');
|
||||
},
|
||||
},
|
||||
});
|
||||
const fallback = cache.get('Aves');
|
||||
assert.equal(await cache.load(), false);
|
||||
assert.equal(cache.get('Aves'), fallback);
|
||||
});
|
||||
|
||||
async function visiblePixels(texture, size) {
|
||||
const svg = texture.includes(';base64,')
|
||||
? Buffer.from(texture.slice(texture.indexOf(',') + 1), 'base64')
|
||||
: Buffer.from(decodeURIComponent(texture.slice(texture.indexOf(',') + 1)));
|
||||
const { data, info } = await sharp(svg)
|
||||
.resize(size, size)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
let pixels = 0;
|
||||
for (let i = 0; i < data.length; i += info.channels)
|
||||
if (Math.max(data[i], data[i + 1], data[i + 2]) > 150 && data[i + 3] > 127)
|
||||
pixels += 1;
|
||||
return pixels;
|
||||
}
|
||||
|
||||
test('compact badge fill remains legible at its near and far footprints', async () => {
|
||||
assert.equal(WILDLIFE_SPRITE_PX, 20);
|
||||
for (const size of [12, 16, WILDLIFE_SPRITE_PX, 22]) {
|
||||
const flightPixels = await visiblePixels(aircraftIcon('airliner'), size);
|
||||
for (const group of WILDLIFE_SPRITE_GROUPS)
|
||||
assert.ok(
|
||||
(await visiblePixels(sprites.get(group), size)) >= flightPixels,
|
||||
`${group} at ${size}px`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
WILDLIFE_MAX_VIEW_DEGREES,
|
||||
WILDLIFE_RETRY_MIN_MS,
|
||||
WILDLIFE_RETRY_MAX_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export { snapWildlifeBox } from './bounds.js';
|
||||
|
||||
const degrees = (radians) => (radians * 180) / Math.PI;
|
||||
const cleanDegree = (value) => Math.round(value * 1e8) / 1e8;
|
||||
|
||||
function validBox(box) {
|
||||
return (
|
||||
!!box &&
|
||||
['south', 'west', 'north', 'east'].every((key) =>
|
||||
Number.isFinite(box[key]),
|
||||
) &&
|
||||
box.south >= -90 &&
|
||||
box.north <= 90 &&
|
||||
box.north >= box.south &&
|
||||
box.west >= -180 &&
|
||||
box.west <= 180 &&
|
||||
box.east >= -180 &&
|
||||
box.east <= 180
|
||||
);
|
||||
}
|
||||
|
||||
function longitudeRanges(box) {
|
||||
return box.west <= box.east
|
||||
? [[box.west, box.east]]
|
||||
: [
|
||||
[box.west, 180],
|
||||
[-180, box.east],
|
||||
];
|
||||
}
|
||||
|
||||
/** Test geographic overlap, including boxes that cross the antimeridian. */
|
||||
export function boxIntersects(a, b) {
|
||||
if (!validBox(a) || !validBox(b) || a.south > b.north || a.north < b.south)
|
||||
return false;
|
||||
return longitudeRanges(a).some(([aw, ae]) =>
|
||||
longitudeRanges(b).some(([bw, be]) => aw <= be && ae >= bw),
|
||||
);
|
||||
}
|
||||
|
||||
/** Split a local camera rectangle into noncrossing observation query boxes. */
|
||||
export function wildlifeViewportBoxes(viewer) {
|
||||
const camera = viewer?.camera;
|
||||
if (typeof camera?.computeViewRectangle !== 'function') return null;
|
||||
const rectangle = camera.computeViewRectangle(
|
||||
viewer?.scene?.globe?.ellipsoid,
|
||||
);
|
||||
if (!rectangle) return null;
|
||||
const box = Object.fromEntries(
|
||||
['south', 'west', 'north', 'east'].map((key) => [
|
||||
key,
|
||||
cleanDegree(degrees(rectangle[key])),
|
||||
]),
|
||||
);
|
||||
if (!validBox(box) || box.north <= box.south) return null;
|
||||
const width =
|
||||
box.east >= box.west ? box.east - box.west : 360 - box.west + box.east;
|
||||
if (
|
||||
width <= 0 ||
|
||||
Math.max(box.north - box.south, width) > WILDLIFE_MAX_VIEW_DEGREES
|
||||
)
|
||||
return null;
|
||||
return longitudeRanges(box)
|
||||
.filter(([west, east]) => east > west)
|
||||
.map(([west, east]) => ({
|
||||
south: box.south,
|
||||
west,
|
||||
north: box.north,
|
||||
east,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 30 seconds, doubling to a four-minute ceiling, for local retry guidance. */
|
||||
export function retryDelayMs(previousMs) {
|
||||
return Number.isFinite(previousMs) && previousMs > 0
|
||||
? Math.min(WILDLIFE_RETRY_MAX_MS, previousMs * 2)
|
||||
: WILDLIFE_RETRY_MIN_MS;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
wildlifeViewportBoxes,
|
||||
snapWildlifeBox,
|
||||
boxIntersects,
|
||||
retryDelayMs,
|
||||
} from './viewport.js';
|
||||
import { ACARTIA_GUARD_BOX } from './policy.js';
|
||||
import { getWildlifeHeightBand } from './model.js';
|
||||
|
||||
const viewer = ({ south, west, north, east }) => ({
|
||||
camera: {
|
||||
computeViewRectangle: () =>
|
||||
Object.fromEntries(
|
||||
Object.entries({ south, west, north, east }).map(([key, value]) => [
|
||||
key,
|
||||
(value * Math.PI) / 180,
|
||||
]),
|
||||
),
|
||||
},
|
||||
scene: { globe: { ellipsoid: {} } },
|
||||
});
|
||||
|
||||
test('local boxes snap outward to 0.1 degrees; wider views to whole degrees', () => {
|
||||
assert.deepEqual(
|
||||
snapWildlifeBox({
|
||||
south: 47.61,
|
||||
west: -122.35,
|
||||
north: 47.79,
|
||||
east: -122.21,
|
||||
}),
|
||||
{ south: 47.6, west: -122.4, north: 47.8, east: -122.2 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
snapWildlifeBox({ south: 40.1, west: -126.2, north: 46.1, east: -121.1 }),
|
||||
{ south: 40, west: -127, north: 47, east: -121 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
snapWildlifeBox({ south: 47.6, west: -122.4, north: 47.8, east: -122.2 }),
|
||||
{ south: 47.6, west: -122.4, north: 47.8, east: -122.2 },
|
||||
);
|
||||
assert.equal(
|
||||
snapWildlifeBox({ south: -20, west: -30, north: 21, east: 10 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('the final snapped span stays within 40 degrees and snapping is idempotent', () => {
|
||||
for (const box of [
|
||||
{ south: 10.2, west: 0, north: 50.1, east: 1 },
|
||||
{ south: 0, west: 10.2, north: 1, east: 50.1 },
|
||||
])
|
||||
assert.equal(snapWildlifeBox(box), null, JSON.stringify(box));
|
||||
for (const box of [
|
||||
{ south: 47.4, west: -122.3, north: 47.6, east: -122.1 },
|
||||
{ south: 10.01, west: -122.3, north: 14.99, east: -117.31 },
|
||||
{ south: 10.01, west: 0, north: 15, east: 5 },
|
||||
{ south: 10.01, west: 0, north: 15.01, east: 5 },
|
||||
{ south: 10, west: 0, north: 50, east: 1 },
|
||||
]) {
|
||||
const snapped = snapWildlifeBox(box);
|
||||
assert.ok(snapped, JSON.stringify(box));
|
||||
assert.deepEqual(snapWildlifeBox(snapped), snapped);
|
||||
}
|
||||
});
|
||||
|
||||
test('intersections handle guard coverage, point boxes and antimeridian', () => {
|
||||
const seattle = { south: 47.5, west: -122.5, north: 47.7, east: -122.2 };
|
||||
assert.equal(boxIntersects(seattle, ACARTIA_GUARD_BOX), true);
|
||||
assert.equal(
|
||||
boxIntersects({ ...seattle, west: 20, east: 21 }, ACARTIA_GUARD_BOX),
|
||||
false,
|
||||
);
|
||||
const dateline = { south: 10, west: 175, north: 20, east: -175 };
|
||||
assert.equal(
|
||||
boxIntersects(dateline, { south: 11, west: 178, north: 12, east: 179 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
boxIntersects(dateline, { south: 11, west: -1, north: 12, east: 1 }),
|
||||
false,
|
||||
);
|
||||
assert.equal(boxIntersects(null, seattle), false);
|
||||
});
|
||||
|
||||
test('viewport rectangles split at 180 degrees and reject overly large or missing views', () => {
|
||||
assert.deepEqual(
|
||||
wildlifeViewportBoxes(
|
||||
viewer({ south: 10, west: 179, north: 12, east: -178 }),
|
||||
),
|
||||
[
|
||||
{ south: 10, west: 179, north: 12, east: 180 },
|
||||
{ south: 10, west: -180, north: 12, east: -178 },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
wildlifeViewportBoxes(
|
||||
viewer({ south: 47, west: -123, north: 48, east: -122 }),
|
||||
),
|
||||
[{ south: 47, west: -123, north: 48, east: -122 }],
|
||||
);
|
||||
assert.equal(
|
||||
wildlifeViewportBoxes(
|
||||
viewer({ south: -21, west: -10, north: 20, east: 10 }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
wildlifeViewportBoxes(
|
||||
viewer({ south: 0, west: 170, north: 10, east: -140 }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(wildlifeViewportBoxes({}), null);
|
||||
});
|
||||
|
||||
test('height bands retain representations across +/-5% and switch beyond +/-10%', () => {
|
||||
assert.equal(getWildlifeHeightBand(3_000_001), 'global');
|
||||
assert.equal(getWildlifeHeightBand(3_000_000), 'regional');
|
||||
assert.equal(getWildlifeHeightBand(3_150_000, 'regional'), 'regional');
|
||||
assert.equal(getWildlifeHeightBand(2_850_000, 'global'), 'global');
|
||||
assert.equal(getWildlifeHeightBand(3_310_000, 'regional'), 'global');
|
||||
assert.equal(getWildlifeHeightBand(2_690_000, 'global'), 'regional');
|
||||
assert.equal(getWildlifeHeightBand(1_600_000), 'regional');
|
||||
assert.equal(getWildlifeHeightBand(1_000_000), 'cells');
|
||||
assert.equal(getWildlifeHeightBand(30_000), 'cards');
|
||||
assert.equal(getWildlifeHeightBand(30_000, 'global'), 'cards');
|
||||
});
|
||||
|
||||
test('retry guidance doubles from 30 seconds to a four-minute ceiling', () => {
|
||||
assert.deepEqual(
|
||||
[undefined, 30_000, 60_000, 120_000, 240_000].map(retryDelayMs),
|
||||
[30_000, 60_000, 120_000, 240_000, 240_000],
|
||||
);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { WILDLIFE_BADGE_GLYPHS } from './layers/wildlifeSightings/sprites.js';
|
||||
|
||||
const SRC_ROOT = fileURLToPath(new URL('.', import.meta.url));
|
||||
const REPO_ROOT = path.resolve(SRC_ROOT, '..');
|
||||
@@ -59,6 +60,8 @@ function sourceFiles(directory = SRC_ROOT) {
|
||||
*/
|
||||
function referencedGlyphs() {
|
||||
const found = new Map();
|
||||
for (const glyph of Object.values(WILDLIFE_BADGE_GLYPHS))
|
||||
found.set(glyph, 'src/layers/wildlifeSightings/sprites.js');
|
||||
for (const file of sourceFiles()) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
const relative = path.relative(REPO_ROOT, file).split(path.sep).join('/');
|
||||
|
||||
@@ -23,6 +23,8 @@ const COCKPIT_REFUSALS = Object.freeze({
|
||||
target: msg('Exit cockpit to fly to a target'),
|
||||
vessel: msg('Exit cockpit to fly to a vessel'),
|
||||
fire: msg('Exit cockpit to fly to a fire'),
|
||||
sighting: msg('Exit cockpit to fly to a sighting'),
|
||||
'sighting-area': msg('Exit cockpit to fly to a sighting area'),
|
||||
route: msg('Exit cockpit to fly to a route'),
|
||||
file: msg('Exit cockpit to fly to a file'),
|
||||
drawing: msg('Exit cockpit to fly to a drawing'),
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildWildlifeSelectedLabel,
|
||||
buildWildlifeThumbnailEntry,
|
||||
} from '../layers/wildlifeSightings/cards.js';
|
||||
import {
|
||||
normalizeOverlayEntry,
|
||||
paintLaneForOverlayEntry,
|
||||
} from './worldOverlay.js';
|
||||
import {
|
||||
clearWorldOverlayTextMeasureCache,
|
||||
measureOverlayEntry,
|
||||
paintOverlayEntry,
|
||||
} from './worldOverlayDraw.js';
|
||||
import { WORLD_OVERLAY_STYLE } from './worldOverlayTokens.js';
|
||||
|
||||
function paintingContext(glyphWidth = 6) {
|
||||
const text = [];
|
||||
const fills = [];
|
||||
const strokes = [];
|
||||
const images = [];
|
||||
return {
|
||||
text,
|
||||
fills,
|
||||
strokes,
|
||||
images,
|
||||
measureText: (text) => ({ width: Array.from(text).length * glyphWidth }),
|
||||
save() {},
|
||||
restore() {},
|
||||
beginPath() {},
|
||||
roundRect() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
fillRect() {},
|
||||
fill() {
|
||||
fills.push(this.fillStyle);
|
||||
},
|
||||
stroke() {
|
||||
strokes.push(this.strokeStyle);
|
||||
},
|
||||
fillText(value) {
|
||||
text.push(value);
|
||||
},
|
||||
drawImage(...args) {
|
||||
images.push(args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('wildlife thumbnail titles paint a word-boundary ellipsis after normalization and width fitting', () => {
|
||||
for (const [commonName, glyphWidth, expected] of [
|
||||
["Bullock's Oriole", 6, "BULLOCK'S…"],
|
||||
['Great Blue Heron', 11, 'GREAT…'],
|
||||
['Superlongunbrokentaxon', 6, 'SUPERLONGUNBRO…'],
|
||||
]) {
|
||||
clearWorldOverlayTextMeasureCache();
|
||||
const entry = normalizeOverlayEntry('wildlife-sightings', {
|
||||
...buildWildlifeThumbnailEntry(
|
||||
{
|
||||
stableId: 'inat:1',
|
||||
commonName,
|
||||
observedAt: 0,
|
||||
photo: {
|
||||
square:
|
||||
'https://inaturalist-open-data.s3.amazonaws.com/photos/1/square.jpg',
|
||||
licence: 'cc-by',
|
||||
attribution: 'Synthetic Photographer',
|
||||
},
|
||||
},
|
||||
0,
|
||||
{ image: { width: 75, height: 75 } },
|
||||
),
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
});
|
||||
const ctx = paintingContext(glyphWidth);
|
||||
entry._overlayLayout = measureOverlayEntry(ctx, entry);
|
||||
paintOverlayEntry(ctx, entry, {
|
||||
rect: {
|
||||
x: 10,
|
||||
y: 20,
|
||||
w: entry._overlayLayout.w,
|
||||
h: entry._overlayLayout.h,
|
||||
},
|
||||
});
|
||||
assert.equal(ctx.text[0], expected);
|
||||
assert.ok(ctx.measureText(ctx.text[0]).width <= 96);
|
||||
assert.ok(entry.accessibilityLabel.includes(commonName));
|
||||
}
|
||||
});
|
||||
|
||||
test('selected thumbnails retain their image in the host selected chrome and lane, without a link line', () => {
|
||||
const entry = normalizeOverlayEntry('wildlife-sightings', {
|
||||
...buildWildlifeThumbnailEntry(
|
||||
{
|
||||
stableId: 'inat:1',
|
||||
commonName: 'Synthetic bird',
|
||||
observedAt: 0,
|
||||
uri: 'https://www.inaturalist.org/observations/1',
|
||||
photo: {
|
||||
square:
|
||||
'https://inaturalist-open-data.s3.amazonaws.com/photos/1/square.jpg',
|
||||
licence: 'cc-by',
|
||||
attribution: 'Synthetic Photographer',
|
||||
},
|
||||
},
|
||||
0,
|
||||
{ selected: true, image: { width: 75, height: 75 } },
|
||||
),
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
});
|
||||
const ctx = paintingContext();
|
||||
entry._overlayLayout = measureOverlayEntry(ctx, entry);
|
||||
paintOverlayEntry(ctx, entry, {
|
||||
rect: {
|
||||
x: 10,
|
||||
y: 20,
|
||||
w: entry._overlayLayout.w,
|
||||
h: entry._overlayLayout.h,
|
||||
},
|
||||
});
|
||||
assert.equal(entry.selected, true);
|
||||
assert.equal(
|
||||
paintLaneForOverlayEntry(entry),
|
||||
paintLaneForOverlayEntry({ selected: true }),
|
||||
);
|
||||
assert.equal(entry._overlayLayout.thumbH, 72);
|
||||
assert.equal(ctx.images.length, 1);
|
||||
assert.ok(ctx.fills.includes(WORLD_OVERLAY_STYLE.selectedBackground));
|
||||
assert.ok(ctx.strokes.includes(WORLD_OVERLAY_STYLE.selectedBorder));
|
||||
assert.ok(!ctx.text.some((line) => String(line).includes('↗')));
|
||||
});
|
||||
|
||||
test('selected wildlife names retain a bounded title with the host selected metrics', () => {
|
||||
for (const origin of ['inaturalist', 'acartia']) {
|
||||
clearWorldOverlayTextMeasureCache();
|
||||
const row = {
|
||||
stableId: `${origin}:1`,
|
||||
origin,
|
||||
commonName:
|
||||
'Synthetic bird with a very long common name that cannot widen the globe label',
|
||||
scientificName: 'Avis synthetica',
|
||||
iconic: 'Aves',
|
||||
photo: {
|
||||
square:
|
||||
'https://inaturalist-open-data.s3.amazonaws.com/photos/1/square.jpg',
|
||||
attribution:
|
||||
'Synthetic Photographer, full attribution belongs in the dialog',
|
||||
licence: 'cc-by',
|
||||
},
|
||||
};
|
||||
const entry = normalizeOverlayEntry('wildlife-sightings-selected', {
|
||||
...buildWildlifeSelectedLabel(row),
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
});
|
||||
const fonts = [];
|
||||
const ctx = {
|
||||
font: '',
|
||||
measureText(text) {
|
||||
fonts.push(this.font);
|
||||
return { width: [...text].length * 6 };
|
||||
},
|
||||
};
|
||||
const layout = measureOverlayEntry(ctx, entry);
|
||||
assert.equal(entry.variant, 'label');
|
||||
assert.equal(
|
||||
entry.selected,
|
||||
true,
|
||||
'selected names use the host selected painter',
|
||||
);
|
||||
assert.equal(entry.protected, true);
|
||||
assert.equal(entry.paintLane, 'selected');
|
||||
assert.equal(
|
||||
paintLaneForOverlayEntry(entry),
|
||||
paintLaneForOverlayEntry({ selected: true }),
|
||||
);
|
||||
assert.equal(entry.pinned, false);
|
||||
assert.equal(entry.interactive, false);
|
||||
assert.equal(entry.placement, 'above');
|
||||
assert.deepEqual(entry.details, []);
|
||||
assert.equal(entry.image, null);
|
||||
assert.equal(layout.h, 31, 'shared selected title and padding');
|
||||
assert.ok(layout.w <= 264, `bounded name, got ${layout.w}px`);
|
||||
assert.deepEqual(fonts, [WORLD_OVERLAY_STYLE.fontSelected]);
|
||||
}
|
||||
});
|
||||
+131
-72
@@ -1,3 +1,5 @@
|
||||
import { WORLD_OVERLAY_OCCLUDER_SELECTORS } from './worldOverlayChrome.js';
|
||||
export { WORLD_OVERLAY_OCCLUDER_SELECTORS } from './worldOverlayChrome.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
measureOverlayEntry,
|
||||
paintOverlayEntry,
|
||||
placementVariants,
|
||||
truncateOverlayTitle,
|
||||
} from './worldOverlayDraw.js';
|
||||
import { WORLD_OVERLAY_STYLE } from './worldOverlayTokens.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
@@ -72,57 +75,6 @@ const PAINT_LANE_INDEX = new Map(
|
||||
WORLD_OVERLAY_PAINT_LANES.map((lane, index) => [lane, index]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Unified UI exclusion inventory. Selectors may match multiple elements; the
|
||||
* service caches their rectangles and observes layout/visibility changes.
|
||||
*
|
||||
* These rectangles are a PLACEMENT policy, never a paint mask. Every element
|
||||
* listed here composites ABOVE the overlay host — map chrome sits at z90–z1000
|
||||
* and the cockpit HUD at z145, against this host's z5/z6 — so the browser
|
||||
* already keeps overlay pixels off it and no canvas needs clipping. What the
|
||||
* inventory buys is readability: a card placed under solid chrome is simply
|
||||
* lost, so the solver PREFERS placements clear of it. When nothing is clear the
|
||||
* entry still renders and the chrome covers it, exactly as it did before the
|
||||
* host existed.
|
||||
*
|
||||
* The list therefore holds only chrome dense enough to swallow a card. The
|
||||
* cockpit's thin translucent line art — rims, arcs, rails, tapes, toplines,
|
||||
* readouts — is deliberately ABSENT under the owner's AR-HUD ruling:
|
||||
* world-space overlay content renders beneath the cockpit's screen-space HUD,
|
||||
* which paints over it by z-order. Those elements are also enormous (the
|
||||
* altitude rim is keyhole-tall, the topline viewport-wide), and excluding them
|
||||
* suppressed essentially the entire cockpit view. Only the two solid,
|
||||
* backdrop-filled cockpit windows survive as exclusions.
|
||||
*/
|
||||
export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([
|
||||
'#title-bar',
|
||||
'#style-indicator',
|
||||
'#top-center-actions',
|
||||
'#traffic-sync-chip',
|
||||
'#cctv-sync-chip',
|
||||
'#left-panel-stack',
|
||||
'#right-context-rail',
|
||||
'.weather-summary',
|
||||
'#pp-toggles',
|
||||
'#command-dock',
|
||||
'#gev-voice-control',
|
||||
'#cesium-credits',
|
||||
'.hud-top-left',
|
||||
'.hud-top-right',
|
||||
'.hud-bottom-left',
|
||||
'.hud-bottom-right',
|
||||
'.hud-bottom-bar',
|
||||
'#space-mission-panel',
|
||||
'#space-mission-panel-host',
|
||||
'#military-awareness-panel',
|
||||
'#bhote-koshi-event-panel',
|
||||
// Cockpit: solid backdrop-filled windows only (both bounded to
|
||||
// `min(340px, 28vw)` wide and `min(42vh, 410px)` tall, both `hidden` until
|
||||
// toggled). Every other cockpit selector was removed — see the block comment.
|
||||
'#cockpit-context',
|
||||
'#cockpit-signal-stream',
|
||||
]);
|
||||
|
||||
/** @typedef {{x:number,y:number,w:number,h:number}} OverlayRect */
|
||||
/**
|
||||
* @typedef {object} WorldOverlayEntry
|
||||
@@ -143,6 +95,8 @@ export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([
|
||||
* @property {number} [maxVisible=Infinity] Source ceiling within a shared domain.
|
||||
* @property {boolean} [moving=false] Enables bounded interval re-solves.
|
||||
* @property {number} [solveIntervalMs=125]
|
||||
* @property {function(Set<string>):void} [onVisibilityChange] Painted entry ids,
|
||||
* reported only on membership changes. The borrowed Set is read-only.
|
||||
*/
|
||||
|
||||
/** @type {Cesium.Viewer|null} */
|
||||
@@ -470,6 +424,11 @@ export function normalizeOverlayEntry(sourceId, entry) {
|
||||
collisionGroup: String(
|
||||
entry.collisionGroup || defaultCollisionGroup(variant),
|
||||
),
|
||||
// Optional lower-priority content must fit around the actual painted
|
||||
// members of another domain, including its sticky positions and exit fades.
|
||||
yieldToCollisionGroup: entry.yieldToCollisionGroup
|
||||
? String(entry.yieldToCollisionGroup)
|
||||
: null,
|
||||
// Explicit field pick rather than `{ ...entry, variant }`: the lane
|
||||
// resolver reads only these four, and a full spread re-invokes every
|
||||
// accessor the source put on its entry (a throwing one would abort
|
||||
@@ -592,12 +551,15 @@ export function normalizeOverlayEntry(sourceId, entry) {
|
||||
metadata: entry.metadata ?? null,
|
||||
thumbnailWidth: entry.thumbnailWidth,
|
||||
thumbnailHeight: entry.thumbnailHeight,
|
||||
thumbnailFit: entry.thumbnailFit === 'contain' ? 'contain' : null,
|
||||
thumbnailFixedWidth: entry.thumbnailFixedWidth === true,
|
||||
thumbnailPadX: entry.thumbnailPadX,
|
||||
thumbnailPadTop: entry.thumbnailPadTop,
|
||||
thumbnailPadBottom: entry.thumbnailPadBottom,
|
||||
thumbnailTitleGap: entry.thumbnailTitleGap,
|
||||
thumbnailTitleHeight: entry.thumbnailTitleHeight,
|
||||
thumbnailTitleChars: entry.thumbnailTitleChars,
|
||||
thumbnailTitleWordBoundary: entry.thumbnailTitleWordBoundary === true,
|
||||
thumbnailBackground: entry.thumbnailBackground,
|
||||
thumbnailTitleColor: entry.thumbnailTitleColor,
|
||||
thumbnailTitleFont: entry.thumbnailTitleFont,
|
||||
@@ -611,13 +573,21 @@ export function normalizeOverlayEntry(sourceId, entry) {
|
||||
_overlayTrackDisplayText: '',
|
||||
_overlayThumbnailTitle:
|
||||
variant === 'thumbnail'
|
||||
? String(entry.title ?? '')
|
||||
.toUpperCase()
|
||||
.slice(
|
||||
0,
|
||||
? entry.thumbnailTitleWordBoundary === true
|
||||
? truncateOverlayTitle(
|
||||
String(entry.title ?? '').toUpperCase(),
|
||||
Math.max(0, Math.floor(Number(entry.thumbnailTitleChars) || 0)) ||
|
||||
undefined,
|
||||
Infinity,
|
||||
)
|
||||
: String(entry.title ?? '')
|
||||
.toUpperCase()
|
||||
.slice(
|
||||
0,
|
||||
Math.max(
|
||||
0,
|
||||
Math.floor(Number(entry.thumbnailTitleChars) || 0),
|
||||
) || undefined,
|
||||
)
|
||||
: '',
|
||||
};
|
||||
normalized._overlayKey = entryKey(source, id);
|
||||
@@ -714,6 +684,12 @@ function normalizeSourceOptions(options = {}, previous = {}) {
|
||||
DEFAULT_MOVING_SOLVE_MS,
|
||||
) || 0,
|
||||
),
|
||||
onVisibilityChange:
|
||||
options.onVisibilityChange === undefined
|
||||
? previous.onVisibilityChange || null
|
||||
: typeof options.onVisibilityChange === 'function'
|
||||
? options.onVisibilityChange
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -844,6 +820,7 @@ function getOrCreateSource(sourceId, options = {}) {
|
||||
cohortLists: [],
|
||||
cohortCount: 0,
|
||||
demandByDomain: new Map(),
|
||||
visibleIds: new Set(),
|
||||
options: normalizeSourceOptions(options),
|
||||
};
|
||||
_sources.set(id, source);
|
||||
@@ -1157,18 +1134,17 @@ export function isOverlayPointVisible(
|
||||
*/
|
||||
function overlayRectIntersectsAny(rect, exclusions, count = exclusions.length) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const other = exclusions[i];
|
||||
if (
|
||||
rect.x < other.x + other.w &&
|
||||
rect.x + rect.w > other.x &&
|
||||
rect.y < other.y + other.h &&
|
||||
rect.y + rect.h > other.y
|
||||
)
|
||||
return true;
|
||||
if (overlayRectsOverlap(rect, exclusions[i])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function overlayRectsOverlap(a, b) {
|
||||
return (
|
||||
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `overlayRectIntersectsAny`, but only against exclusions that composite
|
||||
* BELOW the host — the ones a placement may never overlap at any cost.
|
||||
@@ -1180,14 +1156,7 @@ function overlayRectIntersectsAnyHard(
|
||||
) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const other = exclusions[i];
|
||||
if (!other.hard) continue;
|
||||
if (
|
||||
rect.x < other.x + other.w &&
|
||||
rect.x + rect.w > other.x &&
|
||||
rect.y < other.y + other.h &&
|
||||
rect.y + rect.h > other.y
|
||||
)
|
||||
return true;
|
||||
if (other.hard && overlayRectsOverlap(rect, other)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -2253,6 +2222,7 @@ function addPaintItem(record, placement, temporalAlpha, selected) {
|
||||
item.lane = paintLaneForOverlayEntry(record.entry);
|
||||
item.zIndex = record.entry.zIndex;
|
||||
item.key = record.key;
|
||||
item.collisionHidden = false;
|
||||
_paintQueue[_paintCount++] = item;
|
||||
}
|
||||
|
||||
@@ -2454,6 +2424,7 @@ function paintCustomLane(lane) {
|
||||
}
|
||||
|
||||
function paintEntryItem(item, keyhole, sonar) {
|
||||
if (item.collisionHidden) return;
|
||||
const { record, placement } = item;
|
||||
const entry = record.entry;
|
||||
let keyholeAlpha = 1;
|
||||
@@ -2517,6 +2488,90 @@ function paintEntryItem(item, keyhole, sonar) {
|
||||
_paintedBySource[entry.source]++;
|
||||
}
|
||||
|
||||
function compareYieldingPaintItems(a, b) {
|
||||
const aYields = !!a.record.entry.yieldToCollisionGroup;
|
||||
const bYields = !!b.record.entry.yieldToCollisionGroup;
|
||||
return (
|
||||
Number(aYields) - Number(bYields) ||
|
||||
b.record.entry.priority - a.record.entry.priority ||
|
||||
compareStableKeys(a.key, b.key)
|
||||
);
|
||||
}
|
||||
|
||||
/** Recheck yielding content every painted frame, even between camera solves.
|
||||
* The arbiter's sticky/exit placements are intentionally retained for cards;
|
||||
* labels can only use the remaining rectangles. All buffers are reused. */
|
||||
function declutterYieldingEntries() {
|
||||
let hasYielding = false;
|
||||
for (let i = 0; i < _paintCount; i++)
|
||||
if (_paintQueue[i].record.entry.yieldToCollisionGroup) {
|
||||
hasYielding = true;
|
||||
break;
|
||||
}
|
||||
if (!hasYielding) return;
|
||||
sortPooledRange(_paintQueue, _paintCount, compareYieldingPaintItems);
|
||||
for (let i = 0; i < _paintCount; i++) {
|
||||
const item = _paintQueue[i];
|
||||
const entry = item.record.entry;
|
||||
if (!entry.yieldToCollisionGroup) continue;
|
||||
const placements = item.record.placements;
|
||||
let accepted = null;
|
||||
// Try the arbiter's chosen corner first, then its other valid placements.
|
||||
for (let p = -1; p < placements.length; p++) {
|
||||
const placement = p < 0 ? item.placement : placements[p];
|
||||
if (p >= 0 && placement === item.placement) continue;
|
||||
let blocked = false;
|
||||
for (let j = 0; j < i; j++) {
|
||||
const other = _paintQueue[j];
|
||||
if (other.collisionHidden) continue;
|
||||
const group = other.record.entry.collisionGroup;
|
||||
if (
|
||||
(group === entry.collisionGroup ||
|
||||
group === entry.yieldToCollisionGroup) &&
|
||||
overlayRectsOverlap(placement.rect, other.placement.rect)
|
||||
) {
|
||||
blocked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!blocked) {
|
||||
accepted = placement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (accepted) item.placement = accepted;
|
||||
else item.collisionHidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function syncPaintVisibility(painted = true) {
|
||||
for (let s = 0; s < _sourceList.length; s++) {
|
||||
const source = _sourceList[s];
|
||||
const callback = source.options.onVisibilityChange;
|
||||
if (!callback) continue;
|
||||
const count = painted ? _paintedBySource[source.id] || 0 : 0;
|
||||
let changed = count !== source.visibleIds.size;
|
||||
if (!changed && painted) {
|
||||
for (const id of source.visibleIds) {
|
||||
if (
|
||||
_paintRectByKey.get(entryKey(source.id, id))?.stamp !== _frameStamp
|
||||
) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!changed) continue;
|
||||
source.visibleIds.clear();
|
||||
if (painted)
|
||||
for (let i = 0; i < _paintRectCount; i++) {
|
||||
const rect = _paintRectPool[i];
|
||||
if (rect.sourceId === source.id) source.visibleIds.add(rect.entryId);
|
||||
}
|
||||
callback(source.visibleIds);
|
||||
}
|
||||
}
|
||||
|
||||
function paintFrame(keyhole) {
|
||||
const started = nowMs();
|
||||
const sonar = isCyberSonarActive()
|
||||
@@ -2531,6 +2586,7 @@ function paintFrame(keyhole) {
|
||||
clearCanvas(false, true);
|
||||
_paintRectCount = 0;
|
||||
_hitRectCount = 0;
|
||||
declutterYieldingEntries();
|
||||
sortPooledRange(_paintQueue, _paintCount, comparePaintItems);
|
||||
// The shared canvas is not clipped either. Cards already avoid solid chrome
|
||||
// by placement, and every occluder composites above this host in the DOM, so
|
||||
@@ -2553,6 +2609,7 @@ function paintFrame(keyhole) {
|
||||
_diagnostics.paintRectPoolSize = _paintRectPool.length;
|
||||
_diagnostics.paintMs = nowMs() - started;
|
||||
syncAccessibleActions();
|
||||
syncPaintVisibility();
|
||||
_canvasNeedsClear =
|
||||
_paintRectCount > 0 || activeCustomPaintLaneCount(PAINT_TARGET_SHARED) > 0;
|
||||
}
|
||||
@@ -2609,6 +2666,7 @@ function drawWorldOverlay() {
|
||||
_hitRectCount = 0;
|
||||
syncAccessibleActions();
|
||||
}
|
||||
syncPaintVisibility(false);
|
||||
return;
|
||||
}
|
||||
if (_resizeDirty) ensureCanvasSize();
|
||||
@@ -2743,6 +2801,7 @@ export function destroyWorldOverlay() {
|
||||
) {
|
||||
delete window.__gevWorldOverlay;
|
||||
}
|
||||
syncPaintVisibility(false);
|
||||
_sources.clear();
|
||||
_sourceList.length = 0;
|
||||
for (let i = 0; i < _customPaintLaneList.length; i++) {
|
||||
|
||||
@@ -12,6 +12,15 @@ import {
|
||||
} from '../celestialRing.js';
|
||||
import { createCctvThumbnailOverlayEntry, createFrameSlot } from '../data/cctvCards.js';
|
||||
import { combinedOverlayAlpha } from './worldOverlayDraw.js';
|
||||
import {
|
||||
buildWildlifeLabelEntry,
|
||||
buildWildlifeThumbnailEntry,
|
||||
buildWildlifeSelectedLabel,
|
||||
WILDLIFE_OVERLAY_SOURCE_ID,
|
||||
WILDLIFE_OVERLAY_SOURCE_OPTIONS,
|
||||
WILDLIFE_LABEL_SOURCE_ID,
|
||||
WILDLIFE_LABEL_SOURCE_OPTIONS,
|
||||
} from '../layers/wildlifeSightings/cards.js';
|
||||
import {
|
||||
AMBIENT_CARD_COLLISION_CAPACITY,
|
||||
WORLD_OVERLAY_OCCLUDER_SELECTORS,
|
||||
@@ -1596,6 +1605,171 @@ test('bounded per-source cohorts feed the domain arbiter without universal selec
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
test('wildlife common names use the host ambient collision domain and never paint overlapping labels', (t) => {
|
||||
const env = installMockEnvironment({ width: 1000, height: 600, dpr: 1 });
|
||||
t.after(() => env.cleanup());
|
||||
initWorldOverlay(env.viewer);
|
||||
const entries = [
|
||||
['hawk', 'Red-tailed Hawk', position()],
|
||||
['maple', 'Red Maple', position()],
|
||||
['butterfly', 'Monarch', new Cesium.Cartesian3(-0.6, 0.6, 0)],
|
||||
['unknown', null, position()],
|
||||
].map(([stableId, commonName, anchor]) => {
|
||||
const entry = buildWildlifeLabelEntry({ stableId, commonName, iconic: 'Aves' });
|
||||
return entry && { ...entry, position: anchor, horizonCull: false, edgeFade: 'none' };
|
||||
}).filter(Boolean);
|
||||
assert.equal(entries.length, 3, 'unidentified icons never enter the overlay host');
|
||||
setOverlayEntries(WILDLIFE_LABEL_SOURCE_ID, entries, WILDLIFE_LABEL_SOURCE_OPTIONS);
|
||||
env.postRender.raise();
|
||||
env.advanceTime(200);
|
||||
env.postRender.raise();
|
||||
const rects = entries.map((entry) => getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, entry.id)).filter(Boolean);
|
||||
assert.ok(rects.length >= 2 && rects.length <= 3, 'the host places separated names and relocates or drops coincident names');
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
assert.equal(rects[i].entry.collisionGroup, 'ambient-label');
|
||||
assert.equal(rects[i].entry.protected, false);
|
||||
for (let j = i + 1; j < rects.length; j++) {
|
||||
const a = rects[i], b = rects[j];
|
||||
assert.ok(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const CITY_NOW = Date.parse('2026-11-04T18:00:00Z');
|
||||
function citySighting(id, overrides = {}) {
|
||||
return {
|
||||
stableId: id, commonName: `Synthetic city species ${id}`, iconic: 'Aves',
|
||||
observedAt: CITY_NOW - 60_000, quality: 'research', favesCount: 1,
|
||||
photo: {
|
||||
square: 'https://inaturalist-open-data.s3.amazonaws.com/photos/1/square.jpg',
|
||||
licence: 'cc-by', attribution: 'Synthetic Photographer',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
function cityLabel(row, x, y, width = 1000, height = 600) {
|
||||
return {
|
||||
...buildWildlifeLabelEntry(row, { nowMs: CITY_NOW }),
|
||||
position: positionAtScreen(x, y, width, height), horizonCull: false, edgeFade: 'none',
|
||||
};
|
||||
}
|
||||
function cityCard(row, x, y, width = 1000, height = 600, image = { width: 75, height: 75 }) {
|
||||
return {
|
||||
...buildWildlifeThumbnailEntry(row, CITY_NOW, { image }),
|
||||
position: positionAtScreen(x, y, width, height), horizonCull: false, edgeFade: 'none',
|
||||
maxDistance: Infinity,
|
||||
};
|
||||
}
|
||||
function assertDisjointPaint(entries) {
|
||||
const rects = entries.map(([source, entry]) => getOverlayPaintRect(source, entry.id)).filter(Boolean);
|
||||
for (let i = 0; i < rects.length; i++)
|
||||
for (let j = i + 1; j < rects.length; j++) {
|
||||
const a = rects[i], b = rects[j];
|
||||
assert.ok(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y,
|
||||
`${a.entryId} overlaps ${b.entryId}`);
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
test('close-range dense city labels and photo cards never overlap, including frames between camera solves', (t) => {
|
||||
const env = installMockEnvironment({ width: 1000, height: 600, dpr: 1 });
|
||||
t.after(() => env.cleanup());
|
||||
env.viewer.camera.positionCartographic.height = 9000;
|
||||
initWorldOverlay(env.viewer);
|
||||
const labels = Array.from({ length: 150 }, (_, i) =>
|
||||
cityLabel(citySighting(`city:${i}`, { favesCount: 150 - i }), 65 + (i % 30) * 28, 150 + Math.floor(i / 30) * 62));
|
||||
const cards = Array.from({ length: 8 }, (_, i) =>
|
||||
cityCard(citySighting(`photo:${i}`), 140 + (i % 4) * 210, 180 + Math.floor(i / 4) * 240));
|
||||
const all = [...labels.map((e) => [WILDLIFE_LABEL_SOURCE_ID, e]), ...cards.map((e) => [WILDLIFE_OVERLAY_SOURCE_ID, e])];
|
||||
setOverlayEntries(WILDLIFE_LABEL_SOURCE_ID, labels, WILDLIFE_LABEL_SOURCE_OPTIONS);
|
||||
setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, cards, WILDLIFE_OVERLAY_SOURCE_OPTIONS);
|
||||
for (const ms of [0, 75, 125]) {
|
||||
env.advanceTime(ms);
|
||||
env.postRender.raise();
|
||||
assertDisjointPaint(all);
|
||||
}
|
||||
assert.equal(cards.filter((e) => getOverlayPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, e.id)).length, 8);
|
||||
const placedLabels = labels.filter((e) => getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, e.id));
|
||||
assert.ok(placedLabels.length > 5 && placedLabels.length < labels.length, 'label count is capped by the remaining space');
|
||||
// A pan/zoom reprojects sticky placements on every frame before moveEnd.
|
||||
env.viewer.camera.frustum.projectionMatrix[0] = 0.7;
|
||||
env.viewer.camera.frustum.projectionMatrix[5] = 0.8;
|
||||
env.advanceTime(16);
|
||||
env.postRender.raise();
|
||||
assertDisjointPaint(all);
|
||||
env.moveEnd.raise();
|
||||
env.advanceTime(75);
|
||||
env.postRender.raise();
|
||||
assertDisjointPaint(all);
|
||||
});
|
||||
|
||||
test('labels yield immediately to photo cards, then compete by ambientScore without disturbing the card', (t) => {
|
||||
const env = installMockEnvironment({ width: 400, height: 350, dpr: 1 });
|
||||
t.after(() => env.cleanup());
|
||||
initWorldOverlay(env.viewer);
|
||||
const low = cityLabel(citySighting('low'), 200, 210, 400, 350);
|
||||
const high = cityLabel(citySighting('high', { favesCount: 1000 }), 200, 210, 400, 350);
|
||||
const card = cityCard(citySighting('card', { quality: 'needs_id' }), 200, 210, 400, 350);
|
||||
const all = [[WILDLIFE_LABEL_SOURCE_ID, low], [WILDLIFE_LABEL_SOURCE_ID, high], [WILDLIFE_OVERLAY_SOURCE_ID, card]];
|
||||
setOverlayEntries(WILDLIFE_LABEL_SOURCE_ID, [low, high], WILDLIFE_LABEL_SOURCE_OPTIONS);
|
||||
env.postRender.raise();
|
||||
env.advanceTime(200);
|
||||
env.postRender.raise();
|
||||
assert.equal(assertDisjointPaint(all).length, 2, 'both label slots are initially free');
|
||||
setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, [card], WILDLIFE_OVERLAY_SOURCE_OPTIONS);
|
||||
env.postRender.raise();
|
||||
for (const ms of [1, 74, 125]) {
|
||||
env.advanceTime(ms);
|
||||
env.postRender.raise();
|
||||
assertDisjointPaint(all);
|
||||
assert.ok(getOverlayPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, card.id));
|
||||
assert.ok(getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, high.id));
|
||||
assert.equal(getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, low.id), null);
|
||||
}
|
||||
const before = { ...getOverlayPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, card.id) };
|
||||
const promoted = cityLabel(citySighting('low', { favesCount: 1_000_000 }), 200, 210, 400, 350);
|
||||
setOverlayEntries(WILDLIFE_LABEL_SOURCE_ID, [promoted, high], WILDLIFE_LABEL_SOURCE_OPTIONS);
|
||||
env.postRender.raise();
|
||||
assertDisjointPaint(all);
|
||||
assert.ok(getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, low.id));
|
||||
assert.equal(getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, high.id), null);
|
||||
const after = getOverlayPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, card.id);
|
||||
assert.deepEqual([after.x, after.y, after.w, after.h], [before.x, before.y, before.w, before.h], 'the card retains its sticky placement');
|
||||
const selected = { ...buildWildlifeSelectedLabel(citySighting('selected')), position: card.position, horizonCull: false, edgeFade: 'none' };
|
||||
setOverlayEntries('wildlife-sightings-selected', [selected]);
|
||||
env.postRender.raise();
|
||||
assertDisjointPaint([...all, ['wildlife-sightings-selected', selected]]);
|
||||
// Exit fades still occupy space; labels must wait until the card is gone.
|
||||
setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, [], WILDLIFE_OVERLAY_SOURCE_OPTIONS);
|
||||
for (const ms of [1, 149, 151]) {
|
||||
env.advanceTime(ms);
|
||||
env.postRender.raise();
|
||||
assertDisjointPaint([...all, ['wildlife-sightings-selected', selected]]);
|
||||
}
|
||||
});
|
||||
|
||||
test('photo visibility reports only painted membership changes, ignoring loading cards and idle frames', (t) => {
|
||||
const env = installMockEnvironment({ width: 400, height: 350, dpr: 1 });
|
||||
t.after(() => env.cleanup());
|
||||
initWorldOverlay(env.viewer);
|
||||
const changes = [];
|
||||
const options = { ...WILDLIFE_OVERLAY_SOURCE_OPTIONS, onVisibilityChange: (ids) => changes.push([...ids]) };
|
||||
const r = citySighting('anchor');
|
||||
setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, [cityCard(r, 200, 210, 400, 350, null)], options);
|
||||
env.postRender.raise();
|
||||
assert.deepEqual(changes, []);
|
||||
setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, [cityCard(r, 200, 210, 400, 350)], options);
|
||||
env.postRender.raise();
|
||||
env.advanceTime(200);
|
||||
env.postRender.raise();
|
||||
assert.deepEqual(changes, [['sighting:anchor']]);
|
||||
for (let i = 0; i < 10; i++) env.postRender.raise();
|
||||
assert.equal(changes.length, 1, 'unchanged visibility emits nothing');
|
||||
setOverlaySourceVisible(WILDLIFE_OVERLAY_SOURCE_ID, false);
|
||||
env.postRender.raise();
|
||||
assert.deepEqual(changes, [['sighting:anchor'], []]);
|
||||
});
|
||||
|
||||
test('FIRMS and vessels enlarge the ambient-card lane under an explicit aggregate budget', () => {
|
||||
const env = installMockEnvironment({ width: 1600, height: 900, dpr: 1 });
|
||||
initWorldOverlay(env.viewer);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Solid chrome that card placement should avoid. It composites above the
|
||||
// overlay; the host needs no paint mask. Cockpit line art is intentionally
|
||||
// excluded so its large bounds cannot suppress world-space content.
|
||||
export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([
|
||||
'#title-bar',
|
||||
'#style-indicator',
|
||||
'#top-center-actions',
|
||||
'#traffic-sync-chip',
|
||||
'#cctv-sync-chip',
|
||||
'#left-panel-stack',
|
||||
'#right-context-rail',
|
||||
'.weather-summary',
|
||||
'#pp-toggles',
|
||||
'#command-dock',
|
||||
'#gev-voice-control',
|
||||
'#cesium-credits',
|
||||
'.hud-top-left',
|
||||
'.hud-top-right',
|
||||
'.hud-bottom-left',
|
||||
'.hud-bottom-right',
|
||||
'.hud-bottom-bar',
|
||||
'#space-mission-panel',
|
||||
'#space-mission-panel-host',
|
||||
'#military-awareness-panel',
|
||||
'#bhote-koshi-event-panel',
|
||||
// Only the cockpit's solid windows obstruct readable cards.
|
||||
'#cockpit-context',
|
||||
'#cockpit-signal-stream',
|
||||
]);
|
||||
@@ -312,9 +312,10 @@ function trackDisplayText(entry) {
|
||||
* @returns {{w:number,h:number,padX:number,padY:number,titleH:number,lineH:number,thumbW:number,thumbH:number}}
|
||||
*/
|
||||
export function measureOverlayEntry(ctx, entry, out = {}) {
|
||||
const variant = entry?.selected
|
||||
? 'selected'
|
||||
: String(entry?.variant || 'label');
|
||||
const variant =
|
||||
entry?.selected && entry?.variant !== 'thumbnail'
|
||||
? 'selected'
|
||||
: String(entry?.variant || 'label');
|
||||
const details = Array.isArray(entry?.details) ? entry.details : [];
|
||||
const selected = variant === 'selected';
|
||||
const tracked = variant === 'tracked';
|
||||
@@ -391,7 +392,13 @@ export function measureOverlayEntry(ctx, entry, out = {}) {
|
||||
out.thumbW = Math.max(1, Number(entry?.thumbnailWidth) || 96);
|
||||
out.thumbH = Math.max(1, Number(entry?.thumbnailHeight) || 54);
|
||||
out.w = out.thumbW + out.padX * 2;
|
||||
out.h = out.padY + out.thumbH + out.titleGap + out.titleH + out.padBottom;
|
||||
out.h =
|
||||
out.padY +
|
||||
out.thumbH +
|
||||
out.titleGap +
|
||||
out.titleH +
|
||||
details.length * out.lineH +
|
||||
out.padBottom;
|
||||
} else {
|
||||
out.w = Math.ceil(Math.max(titleWidth, detailWidth)) + out.padX * 2;
|
||||
out.h = out.padY * 2 + out.titleH + details.length * out.lineH;
|
||||
@@ -912,6 +919,38 @@ export function paintCard(
|
||||
return placement.rect;
|
||||
}
|
||||
|
||||
/** Bound a title by code points, preferring a whole word before the ellipsis. */
|
||||
export function truncateOverlayTitle(value, maxChars) {
|
||||
const text = String(value ?? '');
|
||||
if (text.length <= maxChars) return text;
|
||||
const glyphs = Array.from(text);
|
||||
if (glyphs.length <= maxChars) return text;
|
||||
const length = Math.max(0, maxChars - 1);
|
||||
const prefix = glyphs.slice(0, length).join('');
|
||||
const atBoundary = /\s$/u.test(prefix) || /\s/u.test(glyphs[length] || '');
|
||||
const words = atBoundary
|
||||
? prefix.trimEnd()
|
||||
: prefix.replace(/\s+\S*$/u, '').trimEnd();
|
||||
return `${words || prefix.trimEnd()}…`;
|
||||
}
|
||||
|
||||
function fitThumbnailText(ctx, value, font, maxWidth, wordBoundary = false) {
|
||||
const text = String(value ?? '');
|
||||
if (measureWorldOverlayText(ctx, text, font) <= maxWidth) return text;
|
||||
const glyphs = Array.from(text);
|
||||
let low = 0;
|
||||
let high = glyphs.length;
|
||||
while (low < high) {
|
||||
const mid = Math.ceil((low + high) / 2);
|
||||
const candidate = `${glyphs.slice(0, mid).join('')}…`;
|
||||
if (measureWorldOverlayText(ctx, candidate, font) <= maxWidth) low = mid;
|
||||
else high = mid - 1;
|
||||
}
|
||||
return wordBoundary
|
||||
? truncateOverlayTitle(text, low + 1)
|
||||
: `${glyphs.slice(0, low).join('')}…`;
|
||||
}
|
||||
|
||||
/** Paint a thumbnail card. The image slot remains source-owned. */
|
||||
export function paintThumbnail(
|
||||
ctx,
|
||||
@@ -928,6 +967,7 @@ export function paintThumbnail(
|
||||
const thumbW = layout.thumbW || Number(entry.thumbnailWidth) || 96;
|
||||
const thumbH = layout.thumbH || Number(entry.thumbnailHeight) || 54;
|
||||
const titleH = Number.isFinite(layout.titleH) ? layout.titleH : 13;
|
||||
const details = Array.isArray(entry.details) ? entry.details : [];
|
||||
const image = entry.image?.frame ?? entry.image;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
@@ -942,16 +982,38 @@ export function paintThumbnail(
|
||||
);
|
||||
drawAnchorDot(ctx, entry, placement);
|
||||
ctx.globalAlpha = alpha * Math.max(0, Math.min(1, Number(contentAlpha) || 0));
|
||||
ctx.beginPath();
|
||||
roundedRectPath(ctx, x, y, w, h, Number(entry.thumbnailRadius) || 4);
|
||||
ctx.fillStyle = entry.thumbnailBackground || WORLD_OVERLAY_STYLE.background;
|
||||
ctx.fill();
|
||||
if (entry.selected) {
|
||||
drawCardChrome(ctx, entry, placement, true, false);
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
roundedRectPath(ctx, x, y, w, h, Number(entry.thumbnailRadius) || 4);
|
||||
ctx.fillStyle = entry.thumbnailBackground || WORLD_OVERLAY_STYLE.background;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
const imageX = x + padX;
|
||||
const imageY = y + padY;
|
||||
if (image && typeof ctx.drawImage === 'function') {
|
||||
try {
|
||||
ctx.drawImage(image, imageX, imageY, thumbW, thumbH);
|
||||
const sourceW = Number(
|
||||
image.naturalWidth || image.videoWidth || image.width,
|
||||
);
|
||||
const sourceH = Number(
|
||||
image.naturalHeight || image.videoHeight || image.height,
|
||||
);
|
||||
const scale =
|
||||
entry.thumbnailFit === 'contain' && sourceW > 0 && sourceH > 0
|
||||
? Math.min(thumbW / sourceW, thumbH / sourceH)
|
||||
: null;
|
||||
const width = scale == null ? thumbW : sourceW * scale;
|
||||
const height = scale == null ? thumbH : sourceH * scale;
|
||||
ctx.drawImage(
|
||||
image,
|
||||
imageX + (thumbW - width) / 2,
|
||||
imageY + (thumbH - height) / 2,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
} catch {
|
||||
// A source may replace/close a live image slot between frames. Chrome
|
||||
// and text remain valid; the next source update supplies the next frame.
|
||||
@@ -978,12 +1040,41 @@ export function paintThumbnail(
|
||||
ctx.fillStyle = entry.thumbnailTitleColor || WORLD_OVERLAY_STYLE.title;
|
||||
ctx.font = entry.thumbnailTitleFont || WORLD_OVERLAY_STYLE.fontTitle;
|
||||
if (title && titleH > 0) {
|
||||
const label =
|
||||
titleChars > 0
|
||||
? entry.thumbnailTitleWordBoundary
|
||||
? truncateOverlayTitle(title, titleChars)
|
||||
: title.slice(0, titleChars)
|
||||
: title;
|
||||
ctx.fillText(
|
||||
titleChars > 0 ? title.slice(0, titleChars) : title,
|
||||
entry.thumbnailFixedWidth === true
|
||||
? fitThumbnailText(
|
||||
ctx,
|
||||
label,
|
||||
ctx.font,
|
||||
thumbW,
|
||||
entry.thumbnailTitleWordBoundary,
|
||||
)
|
||||
: label,
|
||||
imageX,
|
||||
imageY + thumbH + titleH - 3,
|
||||
);
|
||||
}
|
||||
if (details.length) {
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
||||
ctx.font = WORLD_OVERLAY_STYLE.fontDetail;
|
||||
const lineH = Number.isFinite(layout.lineH) ? layout.lineH : 13;
|
||||
const titleGap = Number.isFinite(layout.titleGap) ? layout.titleGap : 0;
|
||||
for (let i = 0; i < details.length; i++) {
|
||||
ctx.fillText(
|
||||
entry.thumbnailFixedWidth === true
|
||||
? fitThumbnailText(ctx, details[i], ctx.font, thumbW)
|
||||
: details[i],
|
||||
imageX,
|
||||
imageY + thumbH + titleGap + titleH + (i + 1) * lineH - 3,
|
||||
);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
return placement.rect;
|
||||
}
|
||||
@@ -1125,8 +1216,6 @@ export function paintOverlayEntry(
|
||||
return paintTacticalCard(ctx, entry, placement, alpha);
|
||||
if (entry.variant === 'tracked')
|
||||
return paintTracked(ctx, entry, placement, alpha);
|
||||
if (entry.selected || entry.variant === 'selected')
|
||||
return paintSelected(ctx, entry, placement, alpha);
|
||||
if (entry.variant === 'thumbnail') {
|
||||
return paintThumbnail(
|
||||
ctx,
|
||||
@@ -1137,6 +1226,8 @@ export function paintOverlayEntry(
|
||||
contentAlpha,
|
||||
);
|
||||
}
|
||||
if (entry.selected || entry.variant === 'selected')
|
||||
return paintSelected(ctx, entry, placement, alpha);
|
||||
if (entry.variant === 'card') {
|
||||
return paintCard(
|
||||
ctx,
|
||||
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
placementVariants,
|
||||
roundedRectPath,
|
||||
} from './worldOverlayDraw.js';
|
||||
import { createCctvThumbnailOverlayEntry, createFrameSlot } from '../data/cctvCards.js';
|
||||
import {
|
||||
createCctvThumbnailOverlayEntry,
|
||||
createFrameSlot,
|
||||
} from '../data/cctvCards.js';
|
||||
import { normalizeOverlayEntry } from './worldOverlay.js';
|
||||
import {
|
||||
CARD_PLATE_ALPHA,
|
||||
DETECTION_PLATE_BAND,
|
||||
@@ -35,7 +39,9 @@ import {
|
||||
} from './worldOverlayTokens.js';
|
||||
|
||||
function alphaOf(rgba) {
|
||||
const match = /rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([0-9.]+)\s*\)/.exec(String(rgba));
|
||||
const match = /rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([0-9.]+)\s*\)/.exec(
|
||||
String(rgba),
|
||||
);
|
||||
assert.ok(match, `expected an rgba() colour, got ${rgba}`);
|
||||
return Number(match[1]);
|
||||
}
|
||||
@@ -48,28 +54,64 @@ function mockContext() {
|
||||
calls,
|
||||
font: '',
|
||||
globalAlpha: 1,
|
||||
get strokeStyle() { return strokeStyle; },
|
||||
set strokeStyle(value) { strokeStyle = value; calls.push(['strokeStyle', value]); },
|
||||
get lineWidth() { return lineWidth; },
|
||||
set lineWidth(value) { lineWidth = value; calls.push(['lineWidth', value]); },
|
||||
get strokeStyle() {
|
||||
return strokeStyle;
|
||||
},
|
||||
set strokeStyle(value) {
|
||||
strokeStyle = value;
|
||||
calls.push(['strokeStyle', value]);
|
||||
},
|
||||
get lineWidth() {
|
||||
return lineWidth;
|
||||
},
|
||||
set lineWidth(value) {
|
||||
lineWidth = value;
|
||||
calls.push(['lineWidth', value]);
|
||||
},
|
||||
measureCount: 0,
|
||||
measureText(text) {
|
||||
this.measureCount++;
|
||||
return { width: String(text).length * 6 };
|
||||
},
|
||||
save() { calls.push(['save']); },
|
||||
restore() { calls.push(['restore']); },
|
||||
beginPath() { calls.push(['beginPath']); },
|
||||
roundRect(...args) { calls.push(['roundRect', ...args]); },
|
||||
moveTo(...args) { calls.push(['moveTo', ...args]); },
|
||||
lineTo(...args) { calls.push(['lineTo', ...args]); },
|
||||
arcTo(...args) { calls.push(['arcTo', ...args]); },
|
||||
closePath() { calls.push(['closePath']); },
|
||||
fill() { calls.push(['fill']); },
|
||||
stroke() { calls.push(['stroke']); },
|
||||
fillRect(...args) { calls.push(['fillRect', ...args]); },
|
||||
fillText(...args) { calls.push(['fillText', ...args]); },
|
||||
drawImage(...args) { calls.push(['drawImage', ...args]); },
|
||||
save() {
|
||||
calls.push(['save']);
|
||||
},
|
||||
restore() {
|
||||
calls.push(['restore']);
|
||||
},
|
||||
beginPath() {
|
||||
calls.push(['beginPath']);
|
||||
},
|
||||
roundRect(...args) {
|
||||
calls.push(['roundRect', ...args]);
|
||||
},
|
||||
moveTo(...args) {
|
||||
calls.push(['moveTo', ...args]);
|
||||
},
|
||||
lineTo(...args) {
|
||||
calls.push(['lineTo', ...args]);
|
||||
},
|
||||
arcTo(...args) {
|
||||
calls.push(['arcTo', ...args]);
|
||||
},
|
||||
closePath() {
|
||||
calls.push(['closePath']);
|
||||
},
|
||||
fill() {
|
||||
calls.push(['fill']);
|
||||
},
|
||||
stroke() {
|
||||
calls.push(['stroke']);
|
||||
},
|
||||
fillRect(...args) {
|
||||
calls.push(['fillRect', ...args]);
|
||||
},
|
||||
fillText(...args) {
|
||||
calls.push(['fillText', ...args]);
|
||||
},
|
||||
drawImage(...args) {
|
||||
calls.push(['drawImage', ...args]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,14 +127,17 @@ test('every detection theme carries a callout plate inside the ambient band', ()
|
||||
const plate = alphaOf(theme.calloutPlate);
|
||||
const space = alphaOf(theme.calloutPlateSpace);
|
||||
assert.ok(
|
||||
plate >= DETECTION_PLATE_BAND.min * CARD_PLATE_ALPHA
|
||||
&& plate <= DETECTION_PLATE_BAND.max * CARD_PLATE_ALPHA,
|
||||
plate >= DETECTION_PLATE_BAND.min * CARD_PLATE_ALPHA &&
|
||||
plate <= DETECTION_PLATE_BAND.max * CARD_PLATE_ALPHA,
|
||||
`${name} callout plate ${plate} outside the ambient band`,
|
||||
);
|
||||
// The owner's spec: satellites read over the lit Earth disc, so the space
|
||||
// tier pops slightly harder — but never as heavy as the tracked card.
|
||||
assert.ok(space > plate, `${name} space plate must exceed the base plate`);
|
||||
assert.ok(space < CARD_PLATE_ALPHA, `${name} space plate must stay under the card`);
|
||||
assert.ok(
|
||||
space < CARD_PLATE_ALPHA,
|
||||
`${name} space plate must stay under the card`,
|
||||
);
|
||||
// The scanline wash is a separate token and must not have been repurposed.
|
||||
assert.notEqual(theme.calloutPlate, theme.labelBg);
|
||||
}
|
||||
@@ -100,47 +145,78 @@ test('every detection theme carries a callout plate inside the ambient band', ()
|
||||
|
||||
test('an ambient detection callout paints its backing plate under its text', () => {
|
||||
const ctx = mockContext();
|
||||
paintDetectionCallout(ctx, {
|
||||
x: 20, y: 30, w: 80, h: 18,
|
||||
primaryX: 27, microX: 70, baseline: 42,
|
||||
leadFromX: 60, leadFromY: 60, leadToX: 60, leadToY: 48,
|
||||
plate: 'rgba(2, 18, 26, 0.52)',
|
||||
accent: '#22e0ff',
|
||||
label: 'rgba(200, 250, 255, 0.97)',
|
||||
primary: 'JA23NF',
|
||||
micro: 'FL017',
|
||||
font: '10px JetBrains Mono, monospace',
|
||||
microFont: '9px JetBrains Mono, monospace',
|
||||
}, 1);
|
||||
paintDetectionCallout(
|
||||
ctx,
|
||||
{
|
||||
x: 20,
|
||||
y: 30,
|
||||
w: 80,
|
||||
h: 18,
|
||||
primaryX: 27,
|
||||
microX: 70,
|
||||
baseline: 42,
|
||||
leadFromX: 60,
|
||||
leadFromY: 60,
|
||||
leadToX: 60,
|
||||
leadToY: 48,
|
||||
plate: 'rgba(2, 18, 26, 0.52)',
|
||||
accent: '#22e0ff',
|
||||
label: 'rgba(200, 250, 255, 0.97)',
|
||||
primary: 'JA23NF',
|
||||
micro: 'FL017',
|
||||
font: '10px JetBrains Mono, monospace',
|
||||
microFont: '9px JetBrains Mono, monospace',
|
||||
},
|
||||
1,
|
||||
);
|
||||
|
||||
const names = ctx.calls.map(([name]) => name);
|
||||
const firstFill = names.indexOf('fill');
|
||||
const firstText = names.indexOf('fillText');
|
||||
assert.ok(firstFill >= 0, 'the callout must fill a backing plate');
|
||||
assert.ok(firstText > firstFill, 'the plate must be painted before the callsign');
|
||||
assert.ok(
|
||||
firstText > firstFill,
|
||||
'the plate must be painted before the callsign',
|
||||
);
|
||||
// Plate geometry: a rounded rect covering the whole measured label box.
|
||||
assert.deepEqual(
|
||||
ctx.calls.find(([name]) => name === 'roundRect'),
|
||||
['roundRect', 20, 30, 80, 18, 3],
|
||||
);
|
||||
assert.ok(ctx.calls.some(([name, text]) => name === 'fillText' && text === 'JA23NF'));
|
||||
assert.ok(ctx.calls.some(([name, text]) => name === 'fillText' && text === 'FL017'));
|
||||
assert.ok(
|
||||
ctx.calls.some(([name, text]) => name === 'fillText' && text === 'JA23NF'),
|
||||
);
|
||||
assert.ok(
|
||||
ctx.calls.some(([name, text]) => name === 'fillText' && text === 'FL017'),
|
||||
);
|
||||
});
|
||||
|
||||
test('a callout with no micro-field paints one text run and still gets a plate', () => {
|
||||
const ctx = mockContext();
|
||||
paintDetectionCallout(ctx, {
|
||||
x: 0, y: 0, w: 40, h: 18,
|
||||
primaryX: 7, microX: 30, baseline: 12,
|
||||
leadFromX: 20, leadFromY: 30, leadToX: 20, leadToY: 18,
|
||||
plate: 'rgba(2, 18, 26, 0.52)',
|
||||
accent: '#22e0ff',
|
||||
label: '#fff',
|
||||
primary: 'N12345',
|
||||
micro: '',
|
||||
font: '10px mono',
|
||||
microFont: '9px mono',
|
||||
}, 0.5);
|
||||
paintDetectionCallout(
|
||||
ctx,
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 40,
|
||||
h: 18,
|
||||
primaryX: 7,
|
||||
microX: 30,
|
||||
baseline: 12,
|
||||
leadFromX: 20,
|
||||
leadFromY: 30,
|
||||
leadToX: 20,
|
||||
leadToY: 18,
|
||||
plate: 'rgba(2, 18, 26, 0.52)',
|
||||
accent: '#22e0ff',
|
||||
label: '#fff',
|
||||
primary: 'N12345',
|
||||
micro: '',
|
||||
font: '10px mono',
|
||||
microFont: '9px mono',
|
||||
},
|
||||
0.5,
|
||||
);
|
||||
|
||||
assert.ok(ctx.calls.some(([name]) => name === 'fill'));
|
||||
assert.equal(ctx.calls.filter(([name]) => name === 'fillText').length, 1);
|
||||
@@ -167,7 +243,7 @@ test('distance scale matches the legacy infrastructure NearFarScalar curve', ()
|
||||
assert.equal(distanceScale(1_000_000, null), 1);
|
||||
});
|
||||
|
||||
test('altitude scale preserves CCTV\'s exact smoothstep and linear waypoints', () => {
|
||||
test("altitude scale preserves CCTV's exact smoothstep and linear waypoints", () => {
|
||||
const curve = {
|
||||
fullEnd: 1800,
|
||||
midEnd: 6000,
|
||||
@@ -185,13 +261,16 @@ test('altitude scale preserves CCTV\'s exact smoothstep and linear waypoints', (
|
||||
});
|
||||
|
||||
test('the five-channel alpha chain is multiplicative and clamps inputs', () => {
|
||||
assert.equal(combinedOverlayAlpha({
|
||||
sourceAlpha: 0.5,
|
||||
temporalFade: 0.8,
|
||||
distanceFade: 0.5,
|
||||
altitudeFade: 0.5,
|
||||
keyholeEdgeFade: 0.25,
|
||||
}), 0.025);
|
||||
assert.equal(
|
||||
combinedOverlayAlpha({
|
||||
sourceAlpha: 0.5,
|
||||
temporalFade: 0.8,
|
||||
distanceFade: 0.5,
|
||||
altitudeFade: 0.5,
|
||||
keyholeEdgeFade: 0.25,
|
||||
}),
|
||||
0.025,
|
||||
);
|
||||
assert.equal(combinedOverlayAlpha({ sourceAlpha: 2, temporalFade: -1 }), 0);
|
||||
});
|
||||
|
||||
@@ -205,7 +284,10 @@ test('placement variants flip below near the top and stay viewport-clamped', ()
|
||||
viewportHeight: 100,
|
||||
gap: 10,
|
||||
});
|
||||
assert.deepEqual(nearTop.map((item) => item.corner), ['below', 'above', 'right', 'left']);
|
||||
assert.deepEqual(
|
||||
nearTop.map((item) => item.corner),
|
||||
['below', 'above', 'right', 'left'],
|
||||
);
|
||||
for (const placement of nearTop) {
|
||||
assert.ok(placement.rect.x >= 4);
|
||||
assert.ok(placement.rect.y >= 4);
|
||||
@@ -213,14 +295,20 @@ test('placement variants flip below near the top and stay viewport-clamped', ()
|
||||
assert.ok(placement.rect.y + placement.rect.h <= 96);
|
||||
}
|
||||
const reused = nearTop.slice();
|
||||
assert.equal(placementVariants({
|
||||
anchorX: 100,
|
||||
anchorY: 80,
|
||||
width: 40,
|
||||
height: 20,
|
||||
viewportWidth: 200,
|
||||
viewportHeight: 100,
|
||||
}, reused), reused);
|
||||
assert.equal(
|
||||
placementVariants(
|
||||
{
|
||||
anchorX: 100,
|
||||
anchorY: 80,
|
||||
width: 40,
|
||||
height: 20,
|
||||
viewportWidth: 200,
|
||||
viewportHeight: 100,
|
||||
},
|
||||
reused,
|
||||
),
|
||||
reused,
|
||||
);
|
||||
assert.equal(reused[0].corner, 'above');
|
||||
});
|
||||
|
||||
@@ -236,10 +324,16 @@ test('tactical cards retain vertical-only placement and sprite-edge leaders', ()
|
||||
leaderOffset: 14,
|
||||
verticalOnly: true,
|
||||
});
|
||||
assert.deepEqual(placements.map(({ corner }) => corner), ['above', 'below']);
|
||||
assert.deepEqual(
|
||||
placements.map(({ corner }) => corner),
|
||||
['above', 'below'],
|
||||
);
|
||||
assert.equal(placements[0].leadFromY, 80);
|
||||
assert.equal(placements[0].leaderOffset, -14);
|
||||
assert.equal(placements[0].leadToY, placements[0].rect.y + placements[0].rect.h);
|
||||
assert.equal(
|
||||
placements[0].leadToY,
|
||||
placements[0].rect.y + placements[0].rect.h,
|
||||
);
|
||||
});
|
||||
|
||||
test('edge-clamped CCTV placements keep leaders vertical unless the anchor is outside the card', () => {
|
||||
@@ -254,7 +348,11 @@ test('edge-clamped CCTV placements keep leaders vertical unless the anchor is ou
|
||||
leaderOffset: 16,
|
||||
verticalOnly: true,
|
||||
})[0];
|
||||
assert.equal(edgeClamped.rect.x, 4, 'card is horizontally clamped at the viewport edge');
|
||||
assert.equal(
|
||||
edgeClamped.rect.x,
|
||||
4,
|
||||
'card is horizontally clamped at the viewport edge',
|
||||
);
|
||||
assert.equal(edgeClamped.leadFromX, 20);
|
||||
assert.equal(edgeClamped.leadToX, 20, 'leader stays strictly vertical at sx');
|
||||
|
||||
@@ -277,8 +375,11 @@ test('edge-clamped CCTV placements keep leaders vertical unless the anchor is ou
|
||||
// camera". Vertical always, even when the card has been clamped sideways off
|
||||
// its anchor.
|
||||
assert.equal(offRectAnchor.leadFromX, 2);
|
||||
assert.equal(offRectAnchor.leadToX, 2,
|
||||
'an off-rect anchor keeps the leader vertical rather than slanting it to the card');
|
||||
assert.equal(
|
||||
offRectAnchor.leadToX,
|
||||
2,
|
||||
'an off-rect anchor keeps the leader vertical rather than slanting it to the card',
|
||||
);
|
||||
});
|
||||
|
||||
test('rounded rectangles use native support and retain a fallback path', () => {
|
||||
@@ -334,7 +435,8 @@ test('text measurement cache is font-aware and font hooks follow host lifetime',
|
||||
test('text measurement cache caps LRU retention at 1024 entries', () => {
|
||||
clearWorldOverlayTextMeasureCache();
|
||||
const ctx = mockContext();
|
||||
for (let i = 0; i < 1100; i++) measureWorldOverlayText(ctx, `label-${i}`, '10px mono');
|
||||
for (let i = 0; i < 1100; i++)
|
||||
measureWorldOverlayText(ctx, `label-${i}`, '10px mono');
|
||||
assert.equal(getWorldOverlayTextMeasureCacheSize(), 1024);
|
||||
const measured = ctx.measureCount;
|
||||
measureWorldOverlayText(ctx, 'label-0', '10px mono');
|
||||
@@ -355,10 +457,28 @@ test('variant measurement and all six painters remain renderer-local', () => {
|
||||
thumbnailWidth: 96,
|
||||
thumbnailHeight: 54,
|
||||
};
|
||||
const variants = ['label', 'track', 'card', 'thumbnail', 'selected', 'tracked'];
|
||||
const painters = [paintLabel, paintTrack, paintCard, paintThumbnail, paintSelected, paintTracked];
|
||||
const variants = [
|
||||
'label',
|
||||
'track',
|
||||
'card',
|
||||
'thumbnail',
|
||||
'selected',
|
||||
'tracked',
|
||||
];
|
||||
const painters = [
|
||||
paintLabel,
|
||||
paintTrack,
|
||||
paintCard,
|
||||
paintThumbnail,
|
||||
paintSelected,
|
||||
paintTracked,
|
||||
];
|
||||
for (let i = 0; i < variants.length; i++) {
|
||||
const variantEntry = { ...entry, variant: variants[i], selected: variants[i] === 'selected' };
|
||||
const variantEntry = {
|
||||
...entry,
|
||||
variant: variants[i],
|
||||
selected: variants[i] === 'selected',
|
||||
};
|
||||
const layout = measureOverlayEntry(ctx, variantEntry, {});
|
||||
variantEntry._overlayLayout = layout;
|
||||
const placement = placementVariants({
|
||||
@@ -369,18 +489,27 @@ test('variant measurement and all six painters remain renderer-local', () => {
|
||||
viewportWidth: 400,
|
||||
viewportHeight: 300,
|
||||
})[0];
|
||||
assert.equal(painters[i](ctx, variantEntry, placement, 0.5), placement.rect);
|
||||
assert.equal(
|
||||
painters[i](ctx, variantEntry, placement, 0.5),
|
||||
placement.rect,
|
||||
);
|
||||
}
|
||||
assert.ok(ctx.calls.some(([name]) => name === 'drawImage'));
|
||||
assert.ok(ctx.calls.filter(([name]) => name === 'fillText').length >= variants.length);
|
||||
const trackLayout = measureOverlayEntry(ctx, { ...entry, variant: 'track' }, {});
|
||||
assert.ok(
|
||||
ctx.calls.filter(([name]) => name === 'fillText').length >= variants.length,
|
||||
);
|
||||
const trackLayout = measureOverlayEntry(
|
||||
ctx,
|
||||
{ ...entry, variant: 'track' },
|
||||
{},
|
||||
);
|
||||
assert.ok(trackLayout.w >= 'CAMERA 12 · LIVE'.length * 6);
|
||||
});
|
||||
|
||||
test('thumbnail painter preserves the shipped CCTV 104x77 geometry and drawing coordinates', () => {
|
||||
const ctx = mockContext();
|
||||
const frameSlot = createFrameSlot();
|
||||
frameSlot.frame = { width: 192, height: 108 };
|
||||
frameSlot.frame = { width: 192, height: 144 };
|
||||
frameSlot.stamp = 123;
|
||||
const entry = createCctvThumbnailOverlayEntry({
|
||||
id: 'cam-a',
|
||||
@@ -411,7 +540,10 @@ test('thumbnail painter preserves the shipped CCTV 104x77 geometry and drawing c
|
||||
['strokeStyle', 'rgba(107, 232, 255, 0.6)'],
|
||||
'CCTV leader uses the source cyan token rather than the generic leader fallback',
|
||||
);
|
||||
assert.deepEqual(ctx.calls.find(([name]) => name === 'moveTo'), ['moveTo', 200, 184]);
|
||||
assert.deepEqual(
|
||||
ctx.calls.find(([name]) => name === 'moveTo'),
|
||||
['moveTo', 200, 184],
|
||||
);
|
||||
assert.deepEqual(
|
||||
ctx.calls.find(([name]) => name === 'drawImage'),
|
||||
['drawImage', frameSlot.frame, 152, 105, 96, 54],
|
||||
@@ -422,6 +554,69 @@ test('thumbnail painter preserves the shipped CCTV 104x77 geometry and drawing c
|
||||
);
|
||||
});
|
||||
|
||||
test('photo thumbnails letterbox square images and visibly paint attribution and disclaimers', () => {
|
||||
const ctx = mockContext();
|
||||
const entry = {
|
||||
...createCctvThumbnailOverlayEntry({
|
||||
id: 'photo',
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
title: 'Synthetic bird',
|
||||
frameSlot: { frame: { width: 75, height: 75 } },
|
||||
}),
|
||||
thumbnailHeight: 72,
|
||||
thumbnailFit: 'contain',
|
||||
details: ['Photo: Synthetic photographer · CC BY', 'Not a census'],
|
||||
};
|
||||
entry._overlayLayout = measureOverlayEntry(ctx, entry, {});
|
||||
const { w, h, lineH } = entry._overlayLayout;
|
||||
assert.equal(w, 104, 'canvas photos keep the compact image width');
|
||||
assert.equal(h, 4 + 72 + 2 + 13 + 2 * lineH + 4);
|
||||
const placement = { rect: { x: 10, y: 20, w, h } };
|
||||
paintThumbnail(ctx, entry, placement);
|
||||
assert.deepEqual(
|
||||
ctx.calls.find(([name]) => name === 'drawImage'),
|
||||
['drawImage', entry.image.frame, 26, 24, 72, 72],
|
||||
);
|
||||
for (const detail of entry.details)
|
||||
assert.ok(
|
||||
ctx.calls.some(([name, text]) => name === 'fillText' && text === detail),
|
||||
detail,
|
||||
);
|
||||
});
|
||||
|
||||
test('fixed-width photo thumbnails truncate the title and creator inside the 96px image width', () => {
|
||||
const ctx = mockContext();
|
||||
const entry = normalizeOverlayEntry('compact-photos', {
|
||||
...createCctvThumbnailOverlayEntry({
|
||||
id: 'compact-photo',
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
title: 'A long synthetic organism name',
|
||||
frameSlot: { frame: { width: 75, height: 75 } },
|
||||
}),
|
||||
thumbnailHeight: 72,
|
||||
thumbnailFixedWidth: true,
|
||||
details: ['© A very long synthetic photographer name'],
|
||||
});
|
||||
entry._overlayLayout = measureOverlayEntry(ctx, entry, {});
|
||||
const { w, h, lineH } = entry._overlayLayout;
|
||||
assert.equal(w, 104, '96px image plus the shared 4px padding on each side');
|
||||
assert.equal(h, 4 + 72 + 2 + 13 + lineH + 4, 'exactly one creator line');
|
||||
const placement = { rect: { x: 10, y: 20, w, h } };
|
||||
paintThumbnail(ctx, entry, placement);
|
||||
const text = ctx.calls.filter(([name]) => name === 'fillText');
|
||||
assert.equal(text.length, 2);
|
||||
assert.equal(
|
||||
text[0][1],
|
||||
'A LONG SYNTHETI',
|
||||
'the shared title character limit remains active',
|
||||
);
|
||||
assert.equal(text[1][1], '© A very long s…');
|
||||
for (const [, label, x] of text) {
|
||||
assert.equal(x, 14);
|
||||
assert.ok(ctx.measureText(label).width <= 96, label);
|
||||
}
|
||||
});
|
||||
|
||||
test('tracked painter preserves centered multi-line readout metrics', () => {
|
||||
const ctx = mockContext();
|
||||
const entry = {
|
||||
@@ -482,15 +677,24 @@ test('shared tactical painter preserves FIRMS card metrics and top-rule treatmen
|
||||
verticalOnly: true,
|
||||
})[0];
|
||||
assert.equal(paintTacticalCard(ctx, entry, placement, 0.5), placement.rect);
|
||||
assert.deepEqual(ctx.calls.find(([name]) => name === 'moveTo'), ['moveTo', 200, 146]);
|
||||
assert.equal(ctx.calls.filter(([name]) => name === 'stroke').length, 2, 'leader + selected border');
|
||||
assert.deepEqual(
|
||||
ctx.calls.find(([name]) => name === 'moveTo'),
|
||||
['moveTo', 200, 146],
|
||||
);
|
||||
assert.equal(
|
||||
ctx.calls.filter(([name]) => name === 'stroke').length,
|
||||
2,
|
||||
'leader + selected border',
|
||||
);
|
||||
assert.equal(ctx.calls.filter(([name]) => name === 'fillText').length, 3);
|
||||
assert.equal(ctx.calls.at(-1)[0], 'restore');
|
||||
});
|
||||
|
||||
test('selected elbow leader reveals from the glyph before its card fades in', () => {
|
||||
const animation = {
|
||||
leaderAnimationMs: 1000, leaderAnimationStartedAt: 100, leaderDrawRatio: 0.7,
|
||||
leaderAnimationMs: 1000,
|
||||
leaderAnimationStartedAt: 100,
|
||||
leaderDrawRatio: 0.7,
|
||||
};
|
||||
assert.equal(leaderRevealProgress(animation, 100), 0);
|
||||
assert.ok(leaderRevealProgress(animation, 450) > 0.8);
|
||||
@@ -500,20 +704,44 @@ test('selected elbow leader reveals from the glyph before its card fades in', ()
|
||||
assert.equal(tacticalCardRevealAlpha(animation, 1100), 1);
|
||||
const ctx = mockContext();
|
||||
const entry = {
|
||||
variant: 'selected', selected: true, cardStyle: 'tactical', title: 'ALPR-2516',
|
||||
details: ['OSM MAPPED'], accent: '#ff6474', leaderStyle: 'elbow',
|
||||
leaderAnimationMs: 1, leaderAnimationStartedAt: 0,
|
||||
variant: 'selected',
|
||||
selected: true,
|
||||
cardStyle: 'tactical',
|
||||
title: 'ALPR-2516',
|
||||
details: ['OSM MAPPED'],
|
||||
accent: '#ff6474',
|
||||
leaderStyle: 'elbow',
|
||||
leaderAnimationMs: 1,
|
||||
leaderAnimationStartedAt: 0,
|
||||
};
|
||||
entry._overlayLayout = measureOverlayEntry(ctx, entry, {});
|
||||
const placement = placementVariants({
|
||||
anchorX: 240, anchorY: 180, width: entry._overlayLayout.w, height: entry._overlayLayout.h,
|
||||
viewportWidth: 500, viewportHeight: 300, gap: 20, leaderOffset: 30, verticalOnly: true,
|
||||
anchorX: 240,
|
||||
anchorY: 180,
|
||||
width: entry._overlayLayout.w,
|
||||
height: entry._overlayLayout.h,
|
||||
viewportWidth: 500,
|
||||
viewportHeight: 300,
|
||||
gap: 20,
|
||||
leaderOffset: 30,
|
||||
verticalOnly: true,
|
||||
})[0];
|
||||
paintTacticalCard(ctx, entry, placement, 1);
|
||||
assert.deepEqual(ctx.calls.find(([name]) => name === 'moveTo'), ['moveTo', 210, 180]);
|
||||
assert.deepEqual(
|
||||
ctx.calls.find(([name]) => name === 'moveTo'),
|
||||
['moveTo', 210, 180],
|
||||
);
|
||||
const lines = ctx.calls.filter(([name]) => name === 'lineTo');
|
||||
assert.ok(lines.length >= 2, 'leader draws horizontally from the glyph, then vertically to the card');
|
||||
assert.ok(ctx.calls.some(([name, value]) => name === 'strokeStyle' && value === 'rgba(255, 100, 116, 0.95)'));
|
||||
assert.ok(
|
||||
lines.length >= 2,
|
||||
'leader draws horizontally from the glyph, then vertically to the card',
|
||||
);
|
||||
assert.ok(
|
||||
ctx.calls.some(
|
||||
([name, value]) =>
|
||||
name === 'strokeStyle' && value === 'rgba(255, 100, 116, 0.95)',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('track display text is cached across measure and paint and invalidates on content change', () => {
|
||||
@@ -558,16 +786,33 @@ function alphaProbe() {
|
||||
const ctx = mockContext();
|
||||
const alphas = { fill: [], text: [], stroke: [] };
|
||||
const inner = { fill: ctx.fill, fillText: ctx.fillText, stroke: ctx.stroke };
|
||||
ctx.fill = function fill(...args) { alphas.fill.push(this.globalAlpha); return inner.fill.apply(this, args); };
|
||||
ctx.fillText = function fillText(...args) { alphas.text.push(this.globalAlpha); return inner.fillText.apply(this, args); };
|
||||
ctx.stroke = function stroke(...args) { alphas.stroke.push(this.globalAlpha); return inner.stroke.apply(this, args); };
|
||||
ctx.fill = function fill(...args) {
|
||||
alphas.fill.push(this.globalAlpha);
|
||||
return inner.fill.apply(this, args);
|
||||
};
|
||||
ctx.fillText = function fillText(...args) {
|
||||
alphas.text.push(this.globalAlpha);
|
||||
return inner.fillText.apply(this, args);
|
||||
};
|
||||
ctx.stroke = function stroke(...args) {
|
||||
alphas.stroke.push(this.globalAlpha);
|
||||
return inner.stroke.apply(this, args);
|
||||
};
|
||||
return { ctx, alphas };
|
||||
}
|
||||
|
||||
const CALLOUT_FIXTURE = Object.freeze({
|
||||
x: 20, y: 30, w: 80, h: 18,
|
||||
primaryX: 27, microX: 70, baseline: 42,
|
||||
leadFromX: 60, leadFromY: 60, leadToX: 60, leadToY: 48,
|
||||
x: 20,
|
||||
y: 30,
|
||||
w: 80,
|
||||
h: 18,
|
||||
primaryX: 27,
|
||||
microX: 70,
|
||||
baseline: 42,
|
||||
leadFromX: 60,
|
||||
leadFromY: 60,
|
||||
leadToX: 60,
|
||||
leadToY: 48,
|
||||
plate: 'rgba(2, 18, 26, 0.52)',
|
||||
accent: '#22e0ff',
|
||||
label: 'rgba(200, 250, 255, 0.97)',
|
||||
@@ -585,20 +830,36 @@ test('a sky-backed callout feathers its PLATE and nothing else', () => {
|
||||
const ground = alphaProbe();
|
||||
paintDetectionCallout(ground.ctx, { ...CALLOUT_FIXTURE, plateScale: 1 }, 0.8);
|
||||
const sky = alphaProbe();
|
||||
paintDetectionCallout(sky.ctx, { ...CALLOUT_FIXTURE, plateScale: SKY_PLATE_SCALE }, 0.8);
|
||||
paintDetectionCallout(
|
||||
sky.ctx,
|
||||
{ ...CALLOUT_FIXTURE, plateScale: SKY_PLATE_SCALE },
|
||||
0.8,
|
||||
);
|
||||
|
||||
// The plate is the FIRST fill; the tier accent bar is the second.
|
||||
assert.ok(Math.abs(ground.alphas.fill[0] - 0.8) < 1e-12, 'ground keeps the full plate');
|
||||
assert.ok(
|
||||
Math.abs(ground.alphas.fill[0] - 0.8) < 1e-12,
|
||||
'ground keeps the full plate',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(sky.alphas.fill[0] - 0.8 * SKY_PLATE_SCALE) < 1e-12,
|
||||
'sky scales the plate by the token, not by some other number',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sky.alphas.fill.slice(1), ground.alphas.fill.slice(1),
|
||||
sky.alphas.fill.slice(1),
|
||||
ground.alphas.fill.slice(1),
|
||||
'the accent bar must not inherit the plate feather',
|
||||
);
|
||||
assert.deepEqual(sky.alphas.text, ground.alphas.text, 'text opacity is untouched');
|
||||
assert.deepEqual(sky.alphas.stroke, ground.alphas.stroke, 'the leader is untouched');
|
||||
assert.deepEqual(
|
||||
sky.alphas.text,
|
||||
ground.alphas.text,
|
||||
'text opacity is untouched',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sky.alphas.stroke,
|
||||
ground.alphas.stroke,
|
||||
'the leader is untouched',
|
||||
);
|
||||
// Same fill token in both: the theme's plate hue is scaled, never swapped.
|
||||
assert.equal(
|
||||
sky.ctx.calls.find(([name]) => name === 'fillStyle')?.[1],
|
||||
@@ -622,7 +883,12 @@ test('the sky plate scale is a whisper, not a second plate', () => {
|
||||
assert.ok(SKY_PLATE_SCALE > 0 && SKY_PLATE_SCALE <= 0.35);
|
||||
// Against the lightest shipped plate this must resolve to near-invisible.
|
||||
const lightest = Math.min(
|
||||
...Object.values(DETECTION_THEME_MAP).map((theme) => alphaOf(theme.calloutPlate)),
|
||||
...Object.values(DETECTION_THEME_MAP).map((theme) =>
|
||||
alphaOf(theme.calloutPlate),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
lightest * SKY_PLATE_SCALE < 0.12,
|
||||
'the feathered plate must read as bare text',
|
||||
);
|
||||
assert.ok(lightest * SKY_PLATE_SCALE < 0.12, 'the feathered plate must read as bare text');
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
discoverPerfTestFiles,
|
||||
perfBudgetTestOptions,
|
||||
perfTestArgs,
|
||||
runPerfTests,
|
||||
} from '../scripts/run-perf-tests.mjs';
|
||||
|
||||
test('timing assertions are skipped with an actionable message unless explicitly enabled', () => {
|
||||
for (const env of [
|
||||
{},
|
||||
{ GEV_PERF_BUDGETS: '0' },
|
||||
{ GEV_PERF_BUDGETS: 'true' },
|
||||
])
|
||||
assert.match(
|
||||
perfBudgetTestOptions(env).skip,
|
||||
/Timing budgets are opt-in; run npm run test:perf/,
|
||||
);
|
||||
assert.deepEqual(perfBudgetTestOptions({ GEV_PERF_BUDGETS: '1' }), {
|
||||
skip: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('test:perf discovers every benchmark and enables timing budgets in a serial process', (t) => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-perf-runner-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const expected = [
|
||||
'server/providers/example/provider.perf.test.mjs',
|
||||
'src/layers/anotherLayer/model.perf.test.mjs',
|
||||
'src/layers/wildlifeSightings/model.perf.test.mjs',
|
||||
];
|
||||
for (const file of [
|
||||
...expected,
|
||||
'src/layers/anotherLayer/functional.test.mjs',
|
||||
]) {
|
||||
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
|
||||
writeFileSync(path.join(root, file), '');
|
||||
}
|
||||
assert.deepEqual(discoverPerfTestFiles(root), expected);
|
||||
const calls = [];
|
||||
const run = (...args) => {
|
||||
calls.push(args);
|
||||
return { status: 7 };
|
||||
};
|
||||
assert.equal(
|
||||
runPerfTests({
|
||||
root,
|
||||
env: { GEV_PERF_BUDGETS: '0', SENTINEL: 'kept' },
|
||||
run,
|
||||
}),
|
||||
7,
|
||||
);
|
||||
assert.equal(calls.length, 1);
|
||||
const [command, args, options] = calls[0];
|
||||
assert.equal(command, process.execPath);
|
||||
assert.deepEqual(args, perfTestArgs(expected));
|
||||
assert.ok(args.includes('--test-concurrency=1'));
|
||||
assert.ok(args.includes('--test-isolation=none'));
|
||||
assert.equal(options.cwd, root);
|
||||
assert.equal(options.env.GEV_PERF_BUDGETS, '1');
|
||||
assert.equal(options.env.SENTINEL, 'kept');
|
||||
assert.throws(
|
||||
() =>
|
||||
runPerfTests({ root, run: () => ({ error: new Error('spawn failed') }) }),
|
||||
/spawn failed/,
|
||||
);
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
assert.equal(pkg.scripts['test:perf'], 'node scripts/run-perf-tests.mjs');
|
||||
});
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user