mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 20:04:00 +00:00
Compare commits
207
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52187c91d1 | ||
|
|
084a63ab5f | ||
|
|
6af14f7f13 | ||
|
|
8a14029501 | ||
|
|
cc3d390911 | ||
|
|
b99c2f1ade | ||
|
|
ca6eace79e | ||
|
|
39d70fa1c0 | ||
|
|
8455c4bce1 | ||
|
|
872bbefb63 | ||
|
|
2213d3d8cf | ||
|
|
6cadfc9583 | ||
|
|
2cac463c6f | ||
|
|
479286a6a6 | ||
|
|
9b65d41bab | ||
|
|
891eb56091 | ||
|
|
c0a5d4141d | ||
|
|
f5d5da7023 | ||
|
|
021e887f94 | ||
|
|
8ec92eb48c | ||
|
|
5e42a83d88 | ||
|
|
6e4da23cf7 | ||
|
|
574430fb1d | ||
|
|
9c8bb206ef | ||
|
|
c56d5f2f06 | ||
|
|
8da8c05eaf | ||
|
|
5dd7f41436 | ||
|
|
efa4374644 | ||
|
|
13219828e3 | ||
|
|
096b891099 | ||
|
|
3cd7ed68aa | ||
|
|
4effadd3e9 | ||
|
|
ddc14c8f51 | ||
|
|
f7b9a2e5af | ||
|
|
8a7402d80c | ||
|
|
72e2eba70d | ||
|
|
41cc57d630 | ||
|
|
61dfdc771e | ||
|
|
b35c810284 | ||
|
|
d5d824f705 | ||
|
|
91ab9b5811 | ||
|
|
d544613aae | ||
|
|
c4298c4450 | ||
|
|
8917eebe12 | ||
|
|
5bab5ec26d | ||
|
|
5e64e599e1 | ||
|
|
7673351ce0 | ||
|
|
e1ea524b68 | ||
|
|
ea22d16c1a | ||
|
|
4e518821b5 | ||
|
|
3d6423c6f8 | ||
|
|
15fdf65a73 | ||
|
|
39d47a99ab | ||
|
|
3f152a71a1 | ||
|
|
39ff131d60 | ||
|
|
aca5317a2a | ||
|
|
141181f83a | ||
|
|
dda5ac6c00 | ||
|
|
c058ecf21b | ||
|
|
e073e4dbe4 | ||
|
|
a69d0492d0 | ||
|
|
0dbbd9047e | ||
|
|
acc4e63ec4 | ||
|
|
579acf5081 | ||
|
|
5d8f2b878f | ||
|
|
0de347c328 | ||
|
|
a33bc379cb | ||
|
|
58adbb11c7 | ||
|
|
ad009ea1eb | ||
|
|
93d495b0e5 | ||
|
|
3650dd818b | ||
|
|
d807c3007e | ||
|
|
fcad75a7b6 | ||
|
|
d00ced38bf | ||
|
|
4951393997 | ||
|
|
6d7fe53570 | ||
|
|
dac9b5627d | ||
|
|
3438a88cc7 | ||
|
|
42fa974ee3 | ||
|
|
f332150689 | ||
|
|
198c2faf4f | ||
|
|
6479167b50 | ||
|
|
fb0a58ef3c | ||
|
|
5391687d46 | ||
|
|
3cb30c3f97 | ||
|
|
82138478d0 | ||
|
|
bd99000433 | ||
|
|
44106b2bfb | ||
|
|
a20076fc5e | ||
|
|
f53342932d | ||
|
|
dae95e1c7f | ||
|
|
1eef14454a | ||
|
|
c4910d24d0 | ||
|
|
395eba4548 | ||
|
|
8687512fc8 | ||
|
|
2470b39f88 | ||
|
|
d7856109c7 | ||
|
|
4423f4b4a6 | ||
|
|
e3f668831c | ||
|
|
63fd559496 | ||
|
|
0ff48ba059 | ||
|
|
bd8ef09042 | ||
|
|
a75b495cf9 | ||
|
|
ecbe1d99e6 | ||
|
|
14f88b3e08 | ||
|
|
7f8556c691 | ||
|
|
47ea4aff1d | ||
|
|
067383fda8 | ||
|
|
edd6212793 | ||
|
|
b5cef8daf4 | ||
|
|
017a3bc99c | ||
|
|
655d93cc92 | ||
|
|
e2ca8d9a27 | ||
|
|
bc65c274da | ||
|
|
1011dc04fe | ||
|
|
e9e7ed4c1a | ||
|
|
e4fda9d4e6 | ||
|
|
ed0ad9cf0f | ||
|
|
ef4b319f97 | ||
|
|
70f405cf1b | ||
|
|
a67c7969fd | ||
|
|
e5dd7c71ac | ||
|
|
852e93d074 | ||
|
|
29935cc094 | ||
|
|
c64ed40c62 | ||
|
|
5c04b12164 | ||
|
|
33a2876bc0 | ||
|
|
2f9d13f39d | ||
|
|
64377db91a | ||
|
|
1c59105a80 | ||
|
|
ad48305dac | ||
|
|
459b0e8a71 | ||
|
|
219f3e1079 | ||
|
|
3577d19513 | ||
|
|
82f17e694e | ||
|
|
1b0b4a10dc | ||
|
|
55e6c0017c | ||
|
|
46dde909cc | ||
|
|
53ea76251f | ||
|
|
9b7d9ad537 | ||
|
|
99831ea19b | ||
|
|
4c9b27dc20 | ||
|
|
b683b8a64b | ||
|
|
bf73a8a1e1 | ||
|
|
44860df90e | ||
|
|
1f0a8b5f16 | ||
|
|
d36b28f352 | ||
|
|
99520b529c | ||
|
|
eeb20d24e9 | ||
|
|
134597c3cf | ||
|
|
36fceb5675 | ||
|
|
fdecc8c581 | ||
|
|
9d8db97a33 | ||
|
|
2c936ae131 | ||
|
|
b68cd25e9e | ||
|
|
66a5c483c2 | ||
|
|
b7de7c1113 | ||
|
|
44581dccaf | ||
|
|
db8afbae81 | ||
|
|
9e64d517ad | ||
|
|
2aef67197c | ||
|
|
5c74b860b7 | ||
|
|
490c8d1769 | ||
|
|
415f23739f | ||
|
|
2ac73519ec | ||
|
|
75000cf6a9 | ||
|
|
91a3f787d4 | ||
|
|
69152868e4 | ||
|
|
92666fdbaf | ||
|
|
c68f167acb | ||
|
|
04d626a4b1 | ||
|
|
fa4ce4b2a5 | ||
|
|
72ceeb2fce | ||
|
|
16965c4fbc | ||
|
|
ed2aaa2c50 | ||
|
|
53d30c9e80 | ||
|
|
a4c7b20ca8 | ||
|
|
deb02f635e | ||
|
|
c6decfced4 | ||
|
|
b358b5dba1 | ||
|
|
1330605c18 | ||
|
|
9a5cafca27 | ||
|
|
4318f51de4 | ||
|
|
52603f0afe | ||
|
|
0e2ae76f0a | ||
|
|
3a7c7b7c6a | ||
|
|
abaaf0c254 | ||
|
|
a803be1af2 | ||
|
|
7e765a3f93 | ||
|
|
3c26636ab4 | ||
|
|
5b5d942d95 | ||
|
|
2e0d855cf8 | ||
|
|
4ce28c83c0 | ||
|
|
239ba14005 | ||
|
|
cd83fc62fe | ||
|
|
6292b13d75 | ||
|
|
ed1982ab96 | ||
|
|
a9f16fecd3 | ||
|
|
bba786d215 | ||
|
|
295d089377 | ||
|
|
6cf641967b | ||
|
|
7ee51c093a | ||
|
|
621b87c52c | ||
|
|
a953c9a545 | ||
|
|
aa745bcee5 | ||
|
|
81f49406b8 | ||
|
|
28f3243090 |
No files matched your search
+9
-1
@@ -218,6 +218,7 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL
|
||||
# CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras
|
||||
# ONTARIO_511_API_KEY= # free developer key: sign up at 511on.ca/my511/register, then request it at 511on.ca/developers/doc
|
||||
# OTTAWA_CAMERA_CERTIFICATE= # City of Ottawa Traffic Camera Images API certificate (free; request it via the form on traffic.ottawa.ca 'About the data'); without it the Ottawa pack is skipped
|
||||
# CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511
|
||||
# CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack
|
||||
# CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog
|
||||
@@ -238,6 +239,8 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL
|
||||
# CCTV_CATALONIA_ENABLED=1 # set 0 to disable the Servei Català de Trànsit cameras
|
||||
# CCTV_CATALONIA_MAX_SOURCES=150 # cap cameras loaded from Catalonia (~130 exist today)
|
||||
# CCTV_ICELAND_ENABLED=1 # set 0 to disable the Vegagerðin (Iceland) road cameras
|
||||
# CCTV_ICELAND_MAX_SOURCES=250 # cap cameras loaded from Iceland (~500 views exist today)
|
||||
# CCTV_HK_ENABLED=1 # set 0 to disable the Hong Kong Transport Department cameras
|
||||
# CCTV_HK_MAX_SOURCES=1100 # cap cameras loaded from Hong Kong (~1,013 exist today); min 8, max 1200
|
||||
# CCTV_HK_CAMERAS_URL= # override the DATA.GOV.HK camera locations XML URL
|
||||
@@ -268,7 +271,12 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_HOUSTON_CAMERAS_URL= # override the Houston TranStar catalog URL
|
||||
# CCTV_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams
|
||||
# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL
|
||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||
# CCTV_OTTAWA_ENABLED=1 # set 0 to skip City of Ottawa cameras (also needs OTTAWA_CAMERA_CERTIFICATE)
|
||||
# CCTV_OTTAWA_MAX_SOURCES=250 # cap Ottawa cameras (8-500, nearest downtown first)
|
||||
# CCTV_QUEBEC_ENABLED=1 # set 0 to skip the Québec camera locations (MTQ, Montréal, scenic links)
|
||||
# CCTV_QUEBEC_VIDEO_ENABLED= # off by default; set 1 to let viewers play Québec 511 clips in their browser after pressing Play (511 terms of use unconfirmed; see DATA_SOURCES.md)
|
||||
# CCTV_WORLD_CAMERAS_ENABLED=1 # set 0 to skip the Oregon and MLIT (Japan) still cameras
|
||||
# CCTV_MAX_SOURCES=7350 # catalog-wide ceiling: 4,000 slots shared round-robin, the rest reserved for Québec and World
|
||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@ prints anything; never fix that by renaming or deleting PR A's published token.
|
||||
|
||||
The highest-leverage places to jump in:
|
||||
|
||||
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane.
|
||||
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Follow the [CCTV integration workflow](docs/CCTV_INTEGRATION.md) for source review, provider registration, proxy security, tests, and attribution. The proxy only fetches server-registered URLs — never client-supplied ones; see [SECURITY.md](SECURITY.md).
|
||||
- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers/<family>/`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template, and see [Adding a new layer](#adding-a-new-layer) for what the PR needs.
|
||||
- **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened).
|
||||
- **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`.
|
||||
|
||||
+17
-3
@@ -41,6 +41,8 @@ How to read this:
|
||||
| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app |
|
||||
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
|
||||
| **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Prohibited & restricted airspace: FAA Special Use Airspace" |
|
||||
| **iNaturalist API** (`api.inaturalist.org/v2/observations`, `/v1/grid/{z}/{x}/{y}.grid.json`) | Wildlife Sightings layer: verifiable wildlife observed within the last 48 h (whales 7 d), by observation time, in the current view, with taxon, observation time, quality grade and eligible photo | Per-record licences; observations default CC BY-NC. Photos CC0/BY/BY-SA/BY-NC/BY-NC-SA shown; ND and all-rights-reserved photos link-only. Keyless via `/api/inaturalist/observations` (5-min cache; global sample 10 min; stale ≤ 1 h; 60 requests/min and 8,000/day upstream caps). **NonCommercial content:** commercial redistributors must filter to CC0/BY/BY-SA or remove the layer. No AI training on this content | "Wildlife sightings: iNaturalist contributors (licences per record; photos credited on each card)" |
|
||||
| **Acartia** (`acartia.io/api/v1/sightings/current`) | Wildlife Sightings layer: Salish Sea marine mammal reports with validated species, observation time, reporting provider and trust flag; WHALES includes only the frozen cetacean list | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), each provider credited per sighting. Keyless via `/api/acartia/sightings` (10-min cache; stale ≤ 24 h). Positions rounded to 0.01° on the server; free-text comments excluded. Marine-conservation use; Be Whale Wise | "Wildlife sightings: Acartia (CC BY 4.0; each provider credited per sighting) · Be Whale Wise" |
|
||||
| **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles |
|
||||
| **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation |
|
||||
| **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://docs.tomtom.com/legal/terms-and-conditions) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates |
|
||||
@@ -63,9 +65,11 @@ How to read this:
|
||||
| **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" |
|
||||
| **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" |
|
||||
| **Servei Català de Trànsit** (+ Ajuntament de Barcelona, Ajuntament de Terrassa) | CCTV camera catalog + frames, Catalonia | [Llicència oberta d'ús d'informació - Catalunya](https://web.gencat.cat/ca/generalitat/dades-indicadors/dades-obertes/llicencies) — attribution REQUIRED | "Servei Català de Trànsit"; partner-supplied cameras also name their owner |
|
||||
| **Icelandic Road and Coastal Administration (Vegagerðin, IRCA)** | CCTV camera catalog + frames, Iceland road cameras | [CC BY 4.0](https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions) (IRCA terms of use, Act 45/2018) — attribution REQUIRED; no suggestion of IRCA endorsement; IRCA and State logos are excluded; real-time data is provided as is | "Icelandic Road and Coastal Administration (Vegagerðin)", CC BY 4.0 |
|
||||
| **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) |
|
||||
| **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) |
|
||||
| **Hong Kong Transport Department / DATA.GOV.HK** | CCTV camera catalog + frames, Hong Kong traffic snapshot cameras | [DATA.GOV.HK Terms of Use](https://data.gov.hk/en/terms-and-conditions) — attribution REQUIRED | "Hong Kong Transport Department / DATA.GOV.HK" |
|
||||
| **Webcam Romania — Giroc** (`webcamromania.ro`, `w4.webcamromania.ro`) | Optional Giroc camera metadata, JPEG snapshot and live HLS video | [Provider terms](https://webcamromania.ro/termeni-si-conditii/): video belongs to the original camera operator; no open video redistribution licence asserted. The [camera page](https://webcamromania.ro/webcam-orase/webcam-giroc/) offers an embed. Local opt-in via `CCTV_SOURCES_FILE`; media is fetched at runtime, never bundled. | "Webcam Romania · webcamromania.ro"; source link and rights notice in the Data attribution popover |
|
||||
| **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" |
|
||||
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
|
||||
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
|
||||
@@ -98,6 +102,7 @@ How to read this:
|
||||
| **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) |
|
||||
| **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required |
|
||||
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
|
||||
| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Line names come from SMTR’s GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same licence. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 |
|
||||
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
|
||||
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
|
||||
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
|
||||
@@ -119,7 +124,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
**Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent.
|
||||
|
||||
- **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. **Off by default.** The camera endpoints are the eismoinfo.lt website backend and are not among the services listed in Via Lietuva's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44), whose conditions are attribution and reuse on the same terms. Until the statement is confirmed to cover the camera photos, the pack loads only when an operator sets `CCTV_LITHUANIA_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to Eismoinfo. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400.
|
||||
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
|
||||
- **Freeway Bureau and Highway Bureau, MOTC (Taiwan).** National freeway cameras use the Freeway Bureau’s keyless [MOTC 2.0 CCTV table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml), refreshed daily and fetched at runtime (never bundled). Freeway Bureau data follows the [Open Government Data License, Taiwan v1.0](https://data.gov.tw/license); the app credits the Bureau, names the dataset and links the licence. If that catalog is unreachable and no last-good source list exists, the app falls back to the Highway Bureau’s free [provincial-road CCTV XML feed](https://thbapp.thb.gov.tw/opendata/), with attribution as requested by the Bureau. Its MJPEG streams are accepted only from cctv-ss01 through cctv-ss08.thb.gov.tw over HTTPS; the frame proxy extracts one bounded JPEG per request. This fallback covers provincial roads, not national freeways. Neither feed requires an API key. CCTV_TAIWAN_ENABLED=0 disables both; CCTV_TAIWAN_MAX_SOURCES defaults to 600 (bounded to 1–2000).
|
||||
- **Québec CCTV (MTQ / Québec 511, Montréal and scenic links).** `public/quebec-cameras.json` contains 680 MTQ positions from the official georeferenced WFS (CC BY 4.0), 426 Montréal camera locations derived from OSM (ODbL 1.0), and 14 scenic records with per-row provenance and accuracy. The MTQ video is **opt-in** (`CCTV_QUEBEC_VIDEO_ENABLED=1`): the CC BY 4.0 licence covers the camera locations only, and Québec 511's terms for embedding its clips on another site are unconfirmed, so by default MTQ cameras link to the Québec 511 viewer page instead. With the flag set, the 680 MTQ cameras play the operator's five-second MP4 clip directly in the viewer's browser from `www.quebec511.info/Carte/Fenetres/camera.ashx?id=<id>&format=mp4`, only after the viewer presses Play, so Québec 511 sees the viewer's IP address; Québec 511 refreshes those clips every few minutes. A second browser request supplies an approximate map still. The app does not proxy, store, or read video pixels, and no camera images are bundled. The selected-camera credit names both sources: locations MTQ under CC BY 4.0, video Québec 511. Montréal and scenic entries have provider-page links rather than direct frame URLs; their source-page terms apply. The video remains hosted by Québec 511 under its service terms. No API key is required; `CCTV_QUEBEC_ENABLED=0` disables the pack. Confirm Québec 511's terms of use before setting `CCTV_QUEBEC_VIDEO_ENABLED=1` on a public deployment.
|
||||
|
||||
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
||||
- **Sea Marks and Ski Pistes (#10).** Two keyless raster overlay layers under Infrastructure, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline.
|
||||
@@ -167,6 +173,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms.
|
||||
- **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch.
|
||||
- **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/<id>.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel.
|
||||
- **City of Ottawa traffic cameras.** Camera images come from the city's [Traffic Camera Images API](https://traffic.ottawa.ca/en/traffic-map-data-lists-and-resources/about-the-data), which requires an access certificate requested through the city's registration form and allows one certificate and user id to fetch a camera at most once every 60 seconds. Set `OTTAWA_CAMERA_CERTIFICATE`; without it the pack is skipped and makes no request. The camera list is the documented keyless `traffic.ottawa.ca/map/service/camera` JSON (camera number, English and French names, coordinates, owner). Frames are `traffic.ottawa.ca/opendata/camera?c=<number>`; the server adds the certificate and the user id `gev` at fetch time, so no catalog response carries the key, and serves each camera from one shared in-memory frame per minute. MTO cameras carried on the city feed keep their owner as a per-camera credit. The feed publishes no bearing, so headings use the shared id-hash fallback at low confidence, and no status field, so a camera the city takes offline still registers. Frames are never stored on disk. The city states no redistribution licence for the images; the certificate request asks how they will be used, so describe the deployment there. `CCTV_OTTAWA_MAX_SOURCES` caps the pack (default 250, nearest downtown) and `CCTV_OTTAWA_ENABLED=0` turns it off.
|
||||
- **World cams image subset.** The normal CCTV catalog adds 1,176 Oregon TripCheck stills and 1,054 MLIT river cameras from the curated `config/cctv_sources.world-image-cameras.json` export; disable it with `CCTV_WORLD_CAMERAS_ENABLED=0`. The export's Vegagerðin rows are left out because the Iceland pack already loads those cameras. The server validates and pins each image URL to its provider path, fetches a frame only when a camera is viewed, and never stores or redistributes it. [ODOT allows businesses, private citizens and organizations to use TripCheck images and asks for credit](https://www.tripcheck.com/Pages/Frequently-Asked-Questions) ("Camera courtesy of ODOT"); TripCheck also carries partner cameras run by Oregon counties and cities, Caltrans and WSDOT, which keep their owner's name in the camera label. MLIT's [site terms](https://www.river.go.jp/kawabou/kwb_apend/html/caution.html) follow the Government of Japan Standard Terms of Use 2.0 (CC BY 4.0 compatible, commercial use allowed, source credit required) and ask tools not to collect data periodically, pointing regular collection to the river information data service; frames are therefore fetched only on view and refresh at most every ten minutes per camera. Both credits are registered in the Data attribution popover.
|
||||
- **TxDOT ITS cameras.** The catalog comes from the keyless `its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict` endpoint, one call per district. Frames come from the per-camera `GetCctvSnapshotByIcdId` endpoint, which answers JSON carrying a base64 JPEG rather than an image body; the proxy decodes it only for responses from the official TxDOT origin and validates the JPEG header before serving. Only cameras reporting `Device Online` are registered. Districts default to Austin and San Antonio (`CCTV_TXDOT_DISTRICTS`; 25 codes exist statewide); `CCTV_TXDOT_MAX_SOURCES` caps the pack and `CCTV_TXDOT_ENABLED=0` turns it off. Frames are fetched live at request time and are never stored or redistributed.
|
||||
- **Tallinn ristmikud.** Intersection camera stills are fetched live from `ristmikud.tallinn.ee` (`/last/camNNN.jpg`). The curated catalog (`config/cctv_sources.tallinn.json`) supplies coordinates and heading priors; only server-registered ristmikud HTTPS URLs are proxied. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_TALLINN_ENABLED=0`.
|
||||
- **Tarktee / Transpordiamet.** Estonia road-weather camera locations and current JPEG URLs come from keyless DATEX2 endpoints on `tarktee.transpordiamet.ee` (`/api/v1/datex/roadCameraLocations` + `roadCameraImages`). Frame URLs rotate with each capture; the 15-minute CCTV source cache refreshes them. Only `https://tarktee.transpordiamet.ee/images/…` URLs are registered. Disable with `CCTV_TARKTEE_ENABLED=0`.
|
||||
@@ -175,6 +183,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`.
|
||||
- **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent.
|
||||
- **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`.
|
||||
- **Iceland (Vegagerðin / IRCA) cameras.** The camera list is the keyless `https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` JSON feed (about 500 camera views at roughly 170 stations), fetched over https with redirects refused and the body read capped at 2 MiB, since the list decides ids, coordinates and which frame path is proxied. Frames are JPEG stills in `https://www.vegagerdin.is/vgdata/vefmyndavelar/`; only links inside that directory are registered (userinfo, explicit ports and encoded path separators are rejected). Camera ids come from the frame file name. The feed's `Skyring` view description is the camera label, and a stated facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading; a view that states none, or two different ones ("niður á veg", a bare road direction), takes the id-hash low-confidence pose. The feed has no elevation, so cameras carry a flat prior that the client's ground snap corrects. IRCA publishes road camera images under CC BY 4.0 and requires the licensor to be acknowledged without implying endorsement; the pack shows the attribution above (licensor, dataset, CC BY 4.0, terms link and no-endorsement note) in the Data attribution popover, and every camera record carries the licence string. IRCA describes the images as fetched several times per hour (a live frame's `Last-Modified` was about four minutes old on 2026-11-06) and the real-time data as provided as is, without warranty. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_ICELAND_ENABLED=0`.
|
||||
- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`.
|
||||
- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here.
|
||||
- **Hong Kong Transport Department traffic snapshot cameras.** Keyless. The English camera list is the public DATA.GOV.HK XML at `static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml`; frames are stills on `tdcctv.data.one.gov.hk`, and only that host is registered. Frame URLs are rebuilt from each camera key (`https://tdcctv.data.one.gov.hk/{KEY}.JPG`) rather than taken verbatim from the catalog. Stills were probed to answer directly on that origin with no redirect hop. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default nearly the full catalog loads (~1,013; pack cap 1,100), prioritized nearest Central, Tsim Sha Tsui, Sha Tin, Tsuen Wan, Tai Po, Fanling, Tuen Mun, Yuen Long and Tseung Kwan O so a lowered cap still spans Hong Kong Island, Kowloon and the New Territories. When the sum of packs exceeds the catalog-wide ceiling, shared round-robin keeps every lane represented. `CCTV_HK_MAX_SOURCES` (8–1200) changes the cap and `CCTV_HK_ENABLED=0` turns the pack off. The DATA.GOV.HK Terms of Use require attribution to the Government / Transport Department / DATA.GOV.HK.
|
||||
@@ -185,7 +194,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/<id>.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived.
|
||||
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`.
|
||||
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
|
||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
|
||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eighteen registered feeds, covering seventeen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
|
||||
- **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query.
|
||||
|
||||
- **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||
@@ -194,6 +203,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.
|
||||
@@ -211,9 +223,11 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
|
||||
|
||||
| Dataset | Folder | License | Commercial use? | Attribution |
|
||||
| --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| **Datacenters** (4,351) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
|
||||
| **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
|
||||
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map |
|
||||
| **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation |
|
||||
| **Québec CCTV source catalog** (1,120 locations) | `public/quebec-cameras.json` | Mixed: MTQ locations CC BY 4.0; Montréal camera nodes ODbL 1.0; scenic records retain per-record provenance. No video files are bundled or cached. | Location data is per-record; video remains hosted by Québec 511 and its platform terms apply; scenic source pages may have separate terms | MTQ / Données Québec CC BY 4.0; © OpenStreetMap contributors (ODbL); scenic operator and position source shown in the selected-camera panel |
|
||||
| **World camera image subset** (2,230 locations) | `config/cctv_sources.world-image-cameras.json` | Mixed: ODOT TripCheck public images (ODOT permits use with credit) and MLIT site content under terms compatible with CC BY 4.0. Frames are not bundled or persisted; the server holds a bounded in-memory cache. | Per-source terms apply; NZTA Journey Planner web-cache rows are excluded; Vegagerðin rows are served by the Iceland pack instead | Oregon Department of Transportation (Camera courtesy of ODOT) and Japan Ministry of Land, Infrastructure, Transport and Tourism (出典:国土交通省 川の防災情報) |
|
||||
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
|
||||
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
|
||||
|
||||
@@ -426,7 +426,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station
|
||||
|
||||
## 🛰️ What's on the Globe
|
||||
|
||||
Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
|
||||
Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
|
||||
|
||||
| Layer | What you get | Source | Auth |
|
||||
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
@@ -440,11 +440,11 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer
|
||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
|
||||
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
||||
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📹 **CCTV Mesh** | up to 7,350 camera locations (4,000 shared round-robin slots, plus room for 1,120 Québec and 2,230 World image cameras) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Ottawa (City of Ottawa, free access certificate) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Iceland (Vegagerðin) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Québec (MTQ locations with Québec 511 links, Montréal locations, regional webcams) · Oregon (TripCheck) · Japan (MLIT river cameras) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Québec 511 in-browser video (embedding terms unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published or attributed; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
||||
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
||||
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
||||
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
|
||||
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
|
||||
| ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise |
|
||||
@@ -452,6 +452,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer
|
||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) |
|
||||
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
|
||||
| 🚫 **Prohibited & Restricted Airspace** | FAA prohibited (P) and restricted (R) special-use airspace as ground polygons, with name, altitude range and times of use on select. Not the UAS Facility Map or UAS flight restrictions; informational only, not for flight planning | FAA SUA | 🟢 |
|
||||
| 🦉 **Wildlife Sightings** | Wildlife observed within 48 h (whales 7 d): density glow, taxon badges and anchored photo cards with creator/licence credit. Salish Sea marine mammals via Acartia; sensitive locations shown as 0.2° cells. Not a census | iNaturalist, Acartia | 🟢 |
|
||||
| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 |
|
||||
| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 |
|
||||
| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 |
|
||||
@@ -472,7 +473,7 @@ In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Ev
|
||||
|
||||
_The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×._
|
||||
|
||||
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,351), Dams (704), and Submarine Cables (712).
|
||||
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,352), Dams (704), and Submarine Cables (712).
|
||||
|
||||

|
||||
|
||||
@@ -484,9 +485,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
||||
|
||||
> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token), **Google Street View** (your Google Maps key), **KartaView** and **Panoramax** (no key).
|
||||
|
||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it Street Level starts on **KartaView** instead, and the Mapillary chip says which key to add. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
||||
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default when its token is set; otherwise KartaView is. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
|
||||
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
|
||||
|
||||
@@ -638,7 +639,7 @@ and panel. DelDOT uses its official HTTPS HLS catalog links. Disable that
|
||||
pack with `CCTV_DELDOT_ENABLED=0`.
|
||||
|
||||
The server allows two concurrent sessions. Each retains at most 12 segments
|
||||
and 24 MiB in memory; individual downloads are capped at 4 MiB with a ten
|
||||
and 24 MiB in memory; individual downloads are capped at 8 MiB with a ten
|
||||
second deadline. There are no segment files or ffmpeg processes. Redirects,
|
||||
off-origin references, encrypted playlists and non-MPEG-TS segments are refused.
|
||||
Each decoder has its own client lease (at most eight per session), including
|
||||
|
||||
+4
-3
@@ -69,19 +69,20 @@ browser-key fallback for existing single-key setups.
|
||||
The data proxies under `server/providers/` are written so the browser cannot turn the server into an open relay:
|
||||
|
||||
- **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts.
|
||||
- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists and segment bodies while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary.
|
||||
- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists to 256 KiB and segment bodies to 8 MiB while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary.
|
||||
- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio.
|
||||
- **Québec 511 video plays in the browser, opt-in and after Play.** With `CCTV_QUEBEC_VIDEO_ENABLED=1`, `/api/cctv/sources` lists, for each MTQ camera, one URL built server-side from its numeric id on `https://www.quebec511.info/Carte/Fenetres/camera.ashx`; the overlay accepts no other host, path or format. The browser loads that clip only after the viewer presses Play for the selected camera, so Québec 511 sees the viewer's IP address, and the panel says so before Play. Selecting a camera by click, auto-hop, a scene or voice never loads it, and switching cameras stops playback. GEV never proxies, caches or reads the video's pixels. With the flag unset (the default) the browser makes no request to Québec 511.
|
||||
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
|
||||
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
|
||||
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
|
||||
- **Voice place search runs only on the operator's Overpass.** `/api/osm/features` answers only when `OVERPASS_UPSTREAMS` is set and never falls back to a public instance. The client sends a curated preset id, a box, a mode and a limit; the server writes and quotes the Overpass query itself, so no client or model text reaches it. Boxes are capped (36 deg² for a count, 4 deg² for a listing), as are the feature limit, the Overpass timeout and memory, and the response bytes read. Each client (IPv6 clients keyed by /64, as on `/api/overpass`) runs one search at a time and two run at once in all, under a per-client rate limit (12/min) and a global one (30/min); a search gets 22 s across every configured endpoint in turn (Overpass itself is told 20 s), which ends before the voice answer stops waiting at 25 s. The route sits behind the cross-site gate below. Answers are cached in memory and on disk (`.gev-cache/osm-features/`, at most 400 entries and 256 MB, kept a day), keyed to the configured instance so a repointed `OVERPASS_UPSTREAMS` never serves the old one's answers; a malformed answer is never cached.
|
||||
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
|
||||
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. The one exception is a source registered as an MJPEG still (the Taiwan Highway Bureau feeds): its `multipart/x-mixed-replace` answer is read only up to its first JPEG part, capped at 2 MB, and sent as `image/jpeg`; a multipart answer from any other camera falls through. A camera whose API needs an operator credential (the City of Ottawa certificate, `OTTAWA_CAMERA_CERTIFICATE`) has a keyless catalog URL; the frame proxy adds the credential only at fetch time, only for a URL inside that camera's pinned path, so `/api/cctv/sources` and `/api/cctv/stream` never carry it. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
|
||||
- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one.
|
||||
- **Response-size caps and timeouts** on proxied responses.
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights`, `/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,5 @@
|
||||
- Add Rio de Janeiro to the Transit layer: municipal buses and the Mobi-Rio BRT
|
||||
from SMTR's keyless GTFS-Realtime feeds (CC BY 4.0), the layer's first Latin
|
||||
American region. Rio's internal route keys are joined to SMTR's published
|
||||
GTFS `routes.txt`, so a card reads "Route SN731" rather than
|
||||
"Route O0731AAN0A" (IMBrandao, #1481).
|
||||
@@ -0,0 +1,8 @@
|
||||
- Expand CCTV coverage with a keyless Taiwan Highway Bureau fallback, 1,120
|
||||
Québec camera locations, City of Ottawa traffic cameras (needs a free city
|
||||
access certificate, `OTTAWA_CAMERA_CERTIFICATE`), and 2,230 curated
|
||||
image-backed cameras from Oregon and Japan (InoukT, #1506).
|
||||
- Québec 511 in-browser video is opt-in (`CCTV_QUEBEC_VIDEO_ENABLED=1`) and
|
||||
loads only after the viewer presses Play; enforce provider-specific frame
|
||||
refresh limits, and report recognized MLIT maintenance images as degraded
|
||||
instead of healthy camera snapshots.
|
||||
@@ -0,0 +1,6 @@
|
||||
- Voice "tilt the camera" works from the top-down view a fly-to lands on: the
|
||||
camera swings about the place under the crosshair, keeps its heading, and can
|
||||
tilt to a spoken angle ("tilt 45 degrees") or a default oblique view. A tilt,
|
||||
orbit, pan or rotate said with a fly-to ("fly to Paris and orbit it") now
|
||||
waits for the flight to land, and is dropped if you move the map meanwhile
|
||||
(Manjunath, @manjunath22466, #1520).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Cockpit Contact counts and the Contacts list name the provider that
|
||||
answered, marked `FALLBACK` or `STALE` as in the Layers panel. Flights from
|
||||
the adsb.lol fallback no longer read OpenSky, and vessels no longer always
|
||||
read AISStream (German, @gekh, #1521).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Add an adjustable event-age filter across S2 GhostMaps products, defaulting
|
||||
to seven days with 1-, 3-, 14- and 30-day choices and an All markers option.
|
||||
Preserve undated features, save the selected window and expire old events
|
||||
while the map stays open. Thanks @dylanmaniatakes (#1528).
|
||||
@@ -0,0 +1,11 @@
|
||||
- Keep the globe in view from orbit. Cesium's free look (Shift+drag) turned
|
||||
the camera in place, so a few drags from the whole-globe view left only
|
||||
stars, and the pose stayed in the share link. Free look now works only below
|
||||
1,000 km, and above it a drag that starts on the stars orbits the globe
|
||||
instead of turning the camera; a camera move that ends above it with no
|
||||
planet surface in view (inside the scope keyhole when the scope is on) turns
|
||||
straight down; and voice tilt and turns from orbit stop while the planet is
|
||||
still on screen and say so instead of reporting a turn they could not make.
|
||||
Thanks to [Chad Elliott](https://github.com/THEtheChad) for the review that
|
||||
closed the drag path and kept authored scenes framed (Manjunath,
|
||||
@manjunath22466, #1532).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Keep typed chat's view when switching AI Provider: Settings opened from
|
||||
chat stay chat's Settings for the new provider instead of falling back to
|
||||
voice and widening the dock. Opening chat or closing the
|
||||
voice panel now also clears a voice error left on the dock (Manjunath,
|
||||
@manjunath22466, #1546).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Enter Cockpit with only the ridden aircraft's layer on. Contacts is no longer
|
||||
required, and ending Contacts mid-session keeps the view and the layer it
|
||||
rides; turning off the aircraft's own feed ends the view, a missing other
|
||||
feed reads `… FEED OFF · PARTIAL PICTURE`, and double-clicking the followed
|
||||
aircraft or the entry beside its readout enters it (Denis Groshev, @ZloyS,
|
||||
#1556).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Space Missions no longer locks the workspace when its mission index fails to
|
||||
load (429, timeout or bad payload): entry rolls back to the previous layers
|
||||
and camera view and a toast says the index could not load (German, @gekh,
|
||||
#1578).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Fix the translations of the voice area cards, the fire confidence filter,
|
||||
the drone airspace layer and Sea Marks: one word for "landmark" per
|
||||
language, the airspace layer names the airspace again, and Traditional
|
||||
Chinese follows Simplified Chinese again (German, @gekh, #1581).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Keep keyless place search on a nearby exact match for names written in
|
||||
non-Latin scripts instead of discarding the match and retrying without its
|
||||
proximity ranking (mertkayacs, #1585).
|
||||
@@ -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)
|
||||
@@ -0,0 +1,4 @@
|
||||
- Without a Mapillary token, Street Level starts on the first keyless source
|
||||
(KartaView) instead of opening on Mapillary's `KEY REQUIRED` with nothing on
|
||||
the globe. A share link that turns a source off is respected
|
||||
(asturksever, #1601).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Street Traffic offers separate Lines and Dots toggles while live flow is on:
|
||||
Lines draws each road with flow in green, amber or red, and Dots shows or
|
||||
hides the moving dots. A host can choose which road modes it offers; without
|
||||
OpenStreetMap the layer never draws simulated roads (#1609).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Typed chat on OpenAI answers again: OpenAI refused every message because
|
||||
`resolve_area`'s area-id pattern uses Unicode classes Chat Completions does
|
||||
not accept. Chat leaves those patterns out; area ids are still checked
|
||||
before the tool runs (samehkhamis, #1616).
|
||||
@@ -0,0 +1,12 @@
|
||||
- Add Icelandic road cameras from the Icelandic Road and Coastal Administration
|
||||
(Vegagerðin) as a CCTV pack. The camera list is one keyless JSON request to
|
||||
`gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` (about 500 views at 166
|
||||
stations); frames are JPEG stills in `www.vegagerdin.is/vgdata/vefmyndavelar/`,
|
||||
and only links inside that directory are registered. The catalog is fetched
|
||||
over https with redirects refused and a 2 MiB body cap. An explicit facing in
|
||||
the view description ("til vesturs", "í suður", "séð austur") sets a
|
||||
high-confidence heading; other views keep the low-confidence fallback pose.
|
||||
IRCA publishes the images under CC BY 4.0, credited in the Data attribution
|
||||
popover. Loads the 250 views nearest Reykjavík, Akureyri, Egilsstaðir,
|
||||
Ísafjörður and Selfoss; `CCTV_ICELAND_MAX_SOURCES` changes that cap and
|
||||
`CCTV_ICELAND_ENABLED=0` turns the pack off (daikaginza, #1617).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Add an optional Giroc / Webcam Romania CCTV source pack with corrected
|
||||
coordinates, live HLS, a snapshot fallback and provider attribution. Allow
|
||||
HLS segments up to 8 MiB so high-resolution cameras whose segments exceed
|
||||
4 MiB can play, while retaining the 24 MiB per-session cache and existing
|
||||
origin and session limits (NeoXrated23, #1619).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Turning CCTV on opens the camera nearest you instead of the first camera in
|
||||
the catalog, which showed a Sydney camera in London. A share link opens on
|
||||
the camera nearest the shared view once its flight lands (samehkhamis, #1620).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Let the voice assistant read the storm you select in Cyclone advisories:
|
||||
its official advisory, position, intensity and movement. Clearing the
|
||||
selection and the storm leaving the feed are reported apart (kvnloo, #847).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Reduce dead-reckoning work for flights already hidden behind the horizon.
|
||||
Hidden contacts update on a staggered one-in-13-ticks rotation (about once a
|
||||
second) while visible contacts and contacts with fleet models retain the full
|
||||
update path (Wilmer Alzate, #870).
|
||||
@@ -0,0 +1,2 @@
|
||||
- Add Troll Housing to the Datacenters layer (4,352 features) by refreshing
|
||||
Norway from OpenStreetMap data of 2026-10-05 (trygvehhh, #953).
|
||||
@@ -0,0 +1,23 @@
|
||||
[
|
||||
{
|
||||
"id": "ro-giroc-webcamromania",
|
||||
"name": "Giroc — Webcam Romania",
|
||||
"city": "Giroc, Timiș",
|
||||
"cityId": "giroc",
|
||||
"provider": "Webcam Romania · webcamromania.ro",
|
||||
"sourceKind": "configured",
|
||||
"feedType": "hls",
|
||||
"url": "https://w4.webcamromania.ro/giroc/index.m3u8",
|
||||
"snapshotUrl": "https://webcamromania.ro/media/snapshots/01a0267e-d893-7203-a7eb-7ce3c8c99703/latest.jpg",
|
||||
"lat": 45.70028947266781,
|
||||
"lon": 21.233675914004227,
|
||||
"headingDeg": 0,
|
||||
"headingConfidence": "low",
|
||||
"pitchDeg": -17,
|
||||
"fovDeg": 74,
|
||||
"rangeM": 700,
|
||||
"mountHeightM": 24,
|
||||
"groundElevationM": 87,
|
||||
"license": "Public camera: https://webcamromania.ro/webcam-orase/webcam-giroc/ . Provider terms: https://webcamromania.ro/termeni-si-conditii/ . Video rights remain with the camera operator. Coordinates corrected by the contributor; direction, height, elevation and field of view are uncalibrated estimates."
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,155 @@
|
||||
# CCTV integration workflow
|
||||
|
||||
Use this workflow when adding a camera provider or repairing an existing feed.
|
||||
The CCTV catalog comes through the local server; media normally does too. Keep
|
||||
provider-specific URL policy on the server, including any validated browser-only
|
||||
media URL permitted by the exception below.
|
||||
|
||||
## 1. Check the source before coding
|
||||
|
||||
- Identify the catalog endpoint and the actual frame or live-media endpoint.
|
||||
They may be different hosts or use different formats.
|
||||
- Read the provider's license, terms, attribution, rate limits, and access
|
||||
requirements. Publicly reachable data is not automatically licensed for
|
||||
reuse. Record whether the app may display it and whether any use is
|
||||
restricted.
|
||||
- Prefer a public, keyless feed. If credentials are required, keep them in a
|
||||
server-only environment variable; never put a secret in browser code or a
|
||||
Vite-exposed variable. A pack that needs a key is opt-in by that key: with
|
||||
the variable unset it makes no request and logs once (Ontario 511 and
|
||||
Ottawa). Keep the key out of catalog URLs and add it only at fetch time. Use
|
||||
the provider's documented API, not a website endpoint that skips its key. If
|
||||
access terms require an agreement or remain unconfirmed, make the pack
|
||||
opt-in and ensure it makes no request by default.
|
||||
- Confirm the feed can be fetched from the app's Node server environment. A URL
|
||||
opening in a browser does not prove the server can reach it, and browser CORS
|
||||
behavior does not diagnose a server-side fetch.
|
||||
- Treat camera directories and aggregators such as [TW Live](https://tw.live/)
|
||||
as discovery aids. Trace each listing to the original agency or camera owner;
|
||||
their data and media terms govern reuse. For example, SkylineWebcams says
|
||||
only camera hosts may embed its live feed and its terms restrict copying or
|
||||
retransmitting footage, so do not proxy its stream without written
|
||||
authorization ([FAQ](https://www.skylinewebcams.com/support/faq.html),
|
||||
[terms](https://www.skylinewebcams.com/en/terms-of-use.html)).
|
||||
- Decide whether the provider supplies still snapshots or continuous video.
|
||||
Prefer stills unless live video is necessary and supported by the existing
|
||||
proxy path.
|
||||
|
||||
## 2. Add and register the provider pack
|
||||
|
||||
Use the smallest existing provider as a template. Current owners are:
|
||||
|
||||
- `server/providers/cctv/sources.js` for standard open-data loaders.
|
||||
- `server/providers/cctv/<provider>.js` for a provider with its own parser,
|
||||
fallback, or more involved URL policy.
|
||||
- `server/providers/cctv/catalog.js` to import the loader and add it to
|
||||
`LIVE_PACKS`. Each pack is independently gated and the shared catalog cap
|
||||
allocates space across packs.
|
||||
- `server/providers/cctv/constants.js` for provider endpoints, caps, and
|
||||
timeouts.
|
||||
|
||||
Return normalized camera records with stable unique IDs, readable names,
|
||||
`city`/`cityId`, provider attribution, valid `lat`/`lon`, and the correct
|
||||
`feedType`. Include camera heading only when the source actually provides a
|
||||
bearing; descriptive direction text is not a numeric heading. Mark uncertain
|
||||
heading data with the existing low-confidence conventions. Set `sourceKind` so
|
||||
health and source diagnostics identify the provider. Apply a bounded per-pack
|
||||
camera limit and geographic filtering where appropriate; avoid one provider
|
||||
silently consuming the whole shared catalog cap.
|
||||
|
||||
Camera coordinates need their own verification. Prefer the original provider's
|
||||
camera coordinates, then corroborate them against its location description,
|
||||
route/mile marker, or official map. An aggregator's map dot, a town/county name,
|
||||
or a geocoded attraction name is not a precise camera position. Do not pin a
|
||||
camera to a city center or nearby landmark; if its actual location cannot be
|
||||
verified, leave it out until it can. For manually curated coordinates, record
|
||||
the evidence in the provider data or an adjacent comment. For example, TW Live
|
||||
camera pages identify the originating agency; use that agency's catalog for
|
||||
the camera point when available.
|
||||
|
||||
If the source becomes opt-in, use the catalog's opt-in gate (`envOptIn`) and
|
||||
document the flag and reason in `DATA_SOURCES.md`. Otherwise use the normal
|
||||
enabled-by-default flag pattern and document how to disable the pack.
|
||||
|
||||
## 3. Keep frame and media requests server-registered
|
||||
|
||||
The browser should request the local `/api/cctv/frame/:id` or
|
||||
`/api/cctv/media/:id` endpoint. Do not pass an arbitrary upstream URL from the
|
||||
browser, and do not fetch an unvalidated URL supplied by a catalog record.
|
||||
Every provider must have a server-side rule that pins frame and media URLs to
|
||||
the expected scheme, host, and path; use HTTPS where the provider supports it.
|
||||
Use `pinnedCameraUrl()` for URLs that belong under a fixed provider directory,
|
||||
or a stricter provider-specific validator when hosts or paths vary. Register
|
||||
any new permitted image/media origin in the existing
|
||||
`server/providers/cctv` media policy.
|
||||
|
||||
Catalog and media loaders must also:
|
||||
|
||||
- refuse unsafe schemes, credentials, unexpected ports, redirects outside the
|
||||
registered origin/path, and malformed identifiers;
|
||||
- set request deadlines and cap catalog and buffered frame bodies;
|
||||
- parse only the expected format and validate coordinates before returning a
|
||||
camera;
|
||||
- handle offline cameras and partial provider failures without failing the
|
||||
entire merged catalog; and
|
||||
- avoid persistent frame storage unless the provider's terms explicitly allow
|
||||
it and the design has been reviewed.
|
||||
|
||||
For live media, prefer the existing proxy and supported feed type (`mjpeg`,
|
||||
`mp4`, `webm`, or `hls`). A direct browser-only video is a narrow fallback for
|
||||
a source whose terms allow embedding when the browser can play it but the app
|
||||
server cannot fetch it. Unconfirmed embedding terms make it opt-in like any
|
||||
other pack, and the browser loads it only after an explicit play action, never
|
||||
on selection, because the provider sees the viewer's IP address. Build its URL
|
||||
from a validated provider ID on the server; never accept a browser-supplied
|
||||
media URL. Render it in a native DOM
|
||||
`<video>` without `crossOrigin`, and do not read its pixels into canvas, WebGL,
|
||||
or a Cesium texture. If a map preview is needed, a second video request may be
|
||||
paused as a DOM overlay at the camera location; label it as an approximate
|
||||
still and scale it with map distance. If terms disallow embedding, use a source
|
||||
link instead. Read [SECURITY.md](../SECURITY.md) before changing URL
|
||||
validation, redirects, or proxy behavior.
|
||||
|
||||
When an image provider sets a minimum refresh interval, enforce it at the
|
||||
server frame boundary as well as in the client. A bounded in-memory cache and
|
||||
single-flight request per camera prevent projection, panel, and re-selection
|
||||
paths from multiplying upstream requests; do not persist frames unless the
|
||||
provider's terms explicitly allow it.
|
||||
|
||||
## 4. Add focused regression coverage
|
||||
|
||||
Add provider tests under `src/data/` following the existing CCTV test naming
|
||||
and fixtures. Cover at least:
|
||||
|
||||
- representative valid catalog rows and their normalized output;
|
||||
- missing or invalid IDs, coordinates, and frame URLs;
|
||||
- duplicate records and the pack's cap/filter behavior;
|
||||
- unsafe host/path, credentials, and redirect cases;
|
||||
- timeout, oversized, malformed, and unavailable upstream responses; and
|
||||
- the frame or live-media content type the provider actually serves.
|
||||
|
||||
Use `src/data/cctvProxy.test.mjs` for shared proxy/media behavior and
|
||||
`src/data/cctvCatalogCap.test.mjs` for shared catalog allocation. For a
|
||||
browser-only source, verify native playback, denied canvas pixel access, the
|
||||
paused second-player overlay, and its cleanup separately from proxy tests. Run
|
||||
the focused provider and proxy tests, then the standard checks from
|
||||
[CONTRIBUTING.md](../CONTRIBUTING.md) that apply to the change. For runtime
|
||||
verification, inspect `/api/cctv/sources`, `/api/cctv/health`, and the camera's
|
||||
`/api/cctv/frame/:id` or `/api/cctv/stream/:id` response locally. Confirm a
|
||||
successful frame is from the upstream camera by checking its content type and
|
||||
`X-CCTV-Source`; a fallback image is not proof that the upstream feed works.
|
||||
|
||||
## 5. Update source documentation and submit
|
||||
|
||||
- Add the provider, endpoint, terms/license, required attribution, key status,
|
||||
caching behavior, and any restrictions to `DATA_SOURCES.md`.
|
||||
- Update the CCTV source section in `docs/CURRENT-STATE.md` when runtime
|
||||
behavior, flags, caps, or fallback behavior changes.
|
||||
- Register in-app attribution through the existing source/provider credit
|
||||
fields; update `src/data/dataCredits.js` when the source needs a layer-level
|
||||
credit.
|
||||
- Add the required `changelog.d/<pr-number>-<slug>.md` entry for a runtime
|
||||
change, and include the source, license, attribution, key/runtime-fetch
|
||||
details, and verification evidence in the PR description.
|
||||
- For a new layer, also follow the camera/source requirements in the
|
||||
[new-layer PR checklist](../CONTRIBUTING.md#adding-a-new-layer).
|
||||
+365
-74
@@ -65,6 +65,59 @@ settling. Oblique views below 300 km are left alone.
|
||||
`QA_BASE_URL=http://localhost:4173 node scripts/qa-zoom-out-guard.mjs` exercises
|
||||
the real browser controller at desktop and phone widths with keyless fixtures.
|
||||
|
||||
Cesium's free look (Shift+drag, which turns the camera in place) works only
|
||||
below 1,000 km. Above that height a few drags could turn the whole planet out of
|
||||
frame and leave only stars, so Shift+drag does nothing there. Cesium also turns
|
||||
the camera in place for a drag that starts on the stars below its trackball
|
||||
height (7,500 km by default); that height is lowered to 1,000 km, so such a drag
|
||||
orbits the globe above it, as it does higher up, and a tilt (drag or touch)
|
||||
while the screen centre is on the stars there tilts about the planet's edge or
|
||||
does nothing, wherever it starts. Rotation drags that start on the planet, and
|
||||
zoom, are unchanged. How much of the frame shows the planet's surface is
|
||||
measured by casting a 32×18 grid of view rays at the ellipsoid; while the scope
|
||||
mask is on (the default) only the rays inside its keyhole count, since the mask
|
||||
hides or dims the rest. Voice tilt and in-place turns of a free 3D camera above
|
||||
1,000 km stop when about a tenth of the frame still shows the surface (or about
|
||||
as much as it showed when it started), never less than about 1% of the visible
|
||||
frame, and a view that already shows less only keeps or gains what it has. A
|
||||
turn that cannot go half a degree further without crossing that limit is refused
|
||||
with a reason instead of reported as done, and a turn it accepts turns at least
|
||||
that half degree. A voice tilt to an angle (or a bare tilt, to -45°) that the
|
||||
limit stops short of is aimed at the angle where it stops, and its result names
|
||||
that angle (`pitchDeg`) with a `note`. A turn that keeps or gains surface is not refused, even after
|
||||
the window has been resized, except that near the limit, where the planet's edge
|
||||
crosses the sample grid, sampling wobble can occasionally refuse one (most often
|
||||
above about 15,000 km and on phone or ultrawide frames); nothing then turns away
|
||||
from the planet. A turn that starts where the last voice turn left the camera
|
||||
keeps that turn's limit, or a higher one if the planet now fills more, so
|
||||
repeating the request does not take any more; once anything else has moved or
|
||||
turned the camera, a turn is measured from what is on screen. When a camera move
|
||||
ends above 1,000 km with no ray on the surface, the camera turns straight down
|
||||
at the same position and heading. The end of a move is taken from Cesium's
|
||||
move-end event and from a check of its own that samples the pose every 250 ms
|
||||
after a move starts and acts once it has held for half a second, because at some
|
||||
of these poses the camera drifts by a rounding error every frame and Cesium
|
||||
never sees it stop. That check keeps watching until move-end arrives, recovering
|
||||
once after each move that comes to rest, and gives up five minutes after the
|
||||
camera last moved. Limb glow, and a surface sliver up to about one grid cell
|
||||
thick (some 50 px on a 900 px tall frame), can count as out of view. The
|
||||
zoom-out guard above keeps wheel and pinch zoom-outs in view; this catches the
|
||||
other ways a move can end looking away, such as a saved or shared view that did.
|
||||
Views with more surface than that, such as the oblique orbital Director shots,
|
||||
are left as they are, and so are tracked or locked cameras, a camera flight in
|
||||
progress, a Director scene while it plays (holds included), a pointer held
|
||||
down on the globe (a drag held still; with several fingers down, until the
|
||||
last is released) and the 2D and Columbus modes; a check a flight, scene or
|
||||
held pointer defers runs once it ends. A release outside the window that the
|
||||
page never sees counts as given once the window loses focus, the page is
|
||||
hidden, the canvas loses the pointer's capture, or the pointer moves with no
|
||||
button down. A Director
|
||||
shot loaded or replayed outside a run is not a playing scene. A view that loses
|
||||
its surface without a camera move (a resize, or turning the scope on) is left as
|
||||
it is until the next move. A voice turn requested while the camera is tracked is
|
||||
judged by the pose voice's release of the follow camera will leave, before that
|
||||
release, so a refused turn keeps the tracking.
|
||||
|
||||
Analyst region queries resolve unique Natural Earth names offline without
|
||||
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
|
||||
Duplicate names require a geocoded containment anchor; when several entries for
|
||||
@@ -81,7 +134,7 @@ existing batch size and one bounded upstream chunk. Larger requests return HTTP
|
||||
413 before disk-cache loading or upstream work; malformed coordinates remain 400.
|
||||
The browser continues to split larger workloads into sequential 64-point requests.
|
||||
|
||||
Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
|
||||
Traffic holds continuous rendering only while enabled with shown animated dots, a pulsing jam heat-line, or road lines whose geometry is still building (replaced lines stay drawn until their successors are ready, so a rebuild never blinks); hidden dots (`dots` off) and settled flow lines hold nothing. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
|
||||
|
||||
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
|
||||
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
|
||||
@@ -98,6 +151,105 @@ 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`,
|
||||
@@ -589,6 +741,12 @@ geographic seams and releases on disable; forecast animation is not implied.
|
||||
NHC movement speed is converted from miles per hour to rounded knots by the
|
||||
proxy; advisory wind intensity is already in knots and is passed through.
|
||||
Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source.
|
||||
Explicitly selecting a storm publishes one shared `weather-cyclones` record:
|
||||
advisory provenance, source position, the English classification name and,
|
||||
while the proxy serves its cache, the cache fetch time. The automatic row
|
||||
selection does not claim shared context; refreshes do not re-announce it.
|
||||
Clear or disable reports `deliberate`; a storm leaving the feed reports
|
||||
`evicted`, with no fallback.
|
||||
Consult the linked official advisory for safety decisions.
|
||||
|
||||
Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state.
|
||||
@@ -766,9 +924,12 @@ each side, and the empty Voice Preview suggests things to say until voice
|
||||
starts. Settings opened from chat keep chat's frame and show the shared AI
|
||||
provider, the quick-commands option and then the voice preferences (Voice
|
||||
Preview, inactivity timeout and model tier), so they stay in reach while
|
||||
Preview is off, with Back to chat; voice Settings show Back to voice. Escape steps back from either Settings view, closes Voice
|
||||
Preview is off, with Back to chat; voice Settings show Back to voice. Switching
|
||||
AI Provider keeps that view: Settings opened from chat stay chat's Settings,
|
||||
in chat's frame, for the new provider. Escape steps back from either Settings view, closes Voice
|
||||
Preview, and does nothing in chat. Closing chat ends a typed session, and the
|
||||
voice panel's close turns voice off. Asking the assistant from chat to open
|
||||
voice panel's close turns voice off. Opening chat or closing the voice panel
|
||||
also clears a voice error left on the dock. Asking the assistant from chat to open
|
||||
Voice Settings or Voice Preview puts chat away, minimized or not, and shows the
|
||||
voice panel; the typed conversation and its running reply carry on, and the
|
||||
chat button brings them back.
|
||||
@@ -1397,6 +1558,32 @@ owners. Tuner calculations have a pure entry, with existing layer exports
|
||||
preserved. Destruction removes listeners and state subscriptions before ending
|
||||
tuning; a delayed Enable result cannot reveal or refocus removed controls.
|
||||
|
||||
## Giroc / Webcam Romania CCTV source pack
|
||||
|
||||
`config/cctv_sources.romania.json` is an optional single-camera pack for Giroc,
|
||||
Timiș, Romania. To use it, set `CCTV_SOURCES_FILE=config/cctv_sources.romania.json`
|
||||
in the local `.env` and restart the server. Set `CCTV_FORCE_AUSTIN=1` as well to
|
||||
retain the built-in live packs; despite its historical name, this flag applies
|
||||
to all live packs. The default catalog is unchanged.
|
||||
|
||||
The camera uses Webcam Romania's public HLS stream and JPEG snapshot, fetched
|
||||
at runtime without an API key. Its position, 45.70028947266781, 21.233675914004227,
|
||||
was corrected by the contributor rather than copied from the directory's
|
||||
inaccurate pin. Heading, pitch, field of view, mounting height and ground
|
||||
elevation are uncalibrated priors; the camera retains the RAW PRIOR badge.
|
||||
See `DATA_SOURCES.md` for attribution and provider terms. Webcam Romania's
|
||||
terms grant no reuse licence for the video and name no camera operator; the
|
||||
provider's sanctioned route is its iframe embed. Keep this pack to local use
|
||||
and off hosted deployments unless the operator grants permission.
|
||||
|
||||
The existing HLS puller resolves same-origin relative master/media references
|
||||
and serves local leased playlists and segments. It downloads complete segments
|
||||
without forwarding a browser Range header. Its per-segment streamed-byte cap
|
||||
is 8 MiB: Giroc's roughly ten-second 2702×1520 segments can exceed the former
|
||||
4 MiB cap (one measured 6,437,120 bytes). Retained data remains capped at
|
||||
24 MiB and 12 segments per session, with two sessions maximum. Redirect
|
||||
refusal, origin pinning, timeouts and lease expiry are unchanged.
|
||||
|
||||
## NYC DOT TMC CCTV source pack
|
||||
|
||||
The CCTV catalog can add New York City traffic cameras from the NYC DOT
|
||||
@@ -1412,9 +1599,11 @@ from the payload. There is no heading in the feed, so gizmos use the shared
|
||||
id-hash fallback at low confidence.
|
||||
|
||||
Once enabled, the 250 cameras nearest the five borough cores load.
|
||||
`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The catalog-wide ceiling stays at 4,000 and is shared
|
||||
round-robin: no region is dropped outright, but when the packs together exceed
|
||||
the ceiling, adding a pack trims cameras from the largest packs.
|
||||
`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The default catalog limit is
|
||||
7,350: 4,000 shared round-robin slots, plus room above them for the 1,120
|
||||
Québec locations and 2,230 World image sources. Lower explicit
|
||||
`CCTV_MAX_SOURCES` values are honored; reservations only use the capacity
|
||||
above the 4,000 shared slots.
|
||||
|
||||
## City of Toronto RESCU CCTV source pack
|
||||
|
||||
@@ -1430,8 +1619,9 @@ cameras return a 200 "Image Unavailable" placeholder frame instead of an error.
|
||||
|
||||
By default all ~336 cameras load, nearest downtown first.
|
||||
`CCTV_TORONTO_MAX_SOURCES` sets the pack cap (8–500) and
|
||||
`CCTV_TORONTO_ENABLED=0` turns the pack off. The pack shares the 4,000
|
||||
catalog-wide ceiling round-robin with the other packs.
|
||||
`CCTV_TORONTO_ENABLED=0` turns the pack off. The pack shares the default
|
||||
7,350-source catalog limit with the other packs; Québec and the World image
|
||||
pack have reserved slots and the remaining sources use round-robin allocation.
|
||||
|
||||
## Lithuania CCTV source pack
|
||||
|
||||
@@ -1451,24 +1641,8 @@ video; camera poses use the existing low-confidence heading prior.
|
||||
|
||||
## Taiwan CCTV source pack
|
||||
|
||||
The CCTV catalog includes Taiwan national freeway cameras from the Freeway
|
||||
Bureau, MOTC. One keyless request reads the daily MOTC 2.0 `CCTV.xml` table
|
||||
(~1,800 cameras); the request refuses redirects, has a 15-second deadline and a
|
||||
6 MiB response cap, and uses the shared 15-minute cache and fair catalog
|
||||
allocation. A failed download serves the last good table (up to a day old),
|
||||
or an empty pack, and is not retried for an hour: the catalog host does not
|
||||
answer from some networks outside Taiwan, and without the backoff every refresh
|
||||
there would wait out the connect timeout. The table
|
||||
publishes MJPEG stream URLs; each is mapped to the same camera server's
|
||||
single-JPEG path for the same camera number, and only those exact HTTPS pairs
|
||||
on the four `freeway.gov.tw` camera servers are registered with the frame
|
||||
proxy. Camera keys come from the server and camera number, because published
|
||||
CCTV IDs contain Chinese place names and a few repeat across cameras. Poses use
|
||||
the low-confidence heading prior. `CCTV_TAIWAN_ENABLED=0` disables the pack;
|
||||
`CCTV_TAIWAN_MAX_SOURCES` sets its cap (default 600, bounded to 1–2000), and
|
||||
`CCTV_TAIWAN_URL` overrides the table URL.
|
||||
Gate: `src/data/cctvTaiwan.test.mjs`.
|
||||
|
||||
The CCTV catalog includes Taiwan national freeway cameras from the Freeway Bureau, MOTC. The app first reads the daily MOTC 2.0 CCTV.xml table with a keyless HTTPS request, a 15-second deadline, a 6 MiB response cap, and the shared 15-minute cache. If that catalog is unreachable and no last-good source list exists, the loader uses the Highway Bureau’s public provincial-road CCTV XML feed at https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml. The fallback is capped by the same Taiwan source limit and hourly retry backoff; its MJPEG feeds are restricted to the eight cctv-ss01 through cctv-ss08.thb.gov.tw HTTPS servers. The frame proxy returns one bounded JPEG from each stream, not an unbounded video response. The Highway Bureau open-data page states XML downloads are free and asks users to attribute the source; the app credits Highway Bureau, MOTC. These are provincial-road cameras, not a replacement for the national freeway cameras. CCTV_TAIWAN_ENABLED=0 disables both feeds; CCTV_TAIWAN_MAX_SOURCES sets the shared cap (default 600, bounded to 1–2000), and CCTV_TAIWAN_URL overrides the Freeway Bureau table URL.
|
||||
Gate: src/data/cctvTaiwan.test.mjs and src/data/cctvProxy.test.mjs.
|
||||
## DriveBC CCTV source pack
|
||||
|
||||
The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the
|
||||
@@ -1541,6 +1715,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
|
||||
|
||||
@@ -4184,34 +4360,35 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
|
||||
requests use Play; a qualified Play-shaped tool call is normalized to Select so
|
||||
its criteria cannot be silently ignored.
|
||||
|
||||
| Layer | Source | File | Proxy | Update Interval |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
||||
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
|
||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
||||
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
||||
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
||||
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
|
||||
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
|
||||
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
|
||||
| Layer | Source | File | Proxy | Update Interval |
|
||||
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
||||
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
|
||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
||||
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
||||
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
||||
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Iceland (Vegagerðin / IRCA) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
|
||||
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
|
||||
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
|
||||
| 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;
|
||||
@@ -4265,8 +4442,10 @@ provider draws in one colour (`PROVIDER_COLORS` in
|
||||
sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers' switches by id,
|
||||
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google
|
||||
Street View, KartaView and Panoramax are registered. Without
|
||||
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
|
||||
disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||
`MAPILLARY_CLIENT_TOKEN` (and no Graph proxy) Street Level starts on the first
|
||||
keyless provider (KartaView), including a restored "Mapillary on" unless the link
|
||||
switches the keyless providers off; filter-only updates never change the source.
|
||||
With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
|
||||
requests for a tile.
|
||||
|
||||
@@ -4280,7 +4459,7 @@ the camera, nearest first, at most 9 (the halves of a view across
|
||||
the date line count a shared tile once). Lines are draped
|
||||
`GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain
|
||||
and Google 3D tiles alike; cones and the photo marker are ground-clamped
|
||||
billboards with a horizon cull. Without `MAPILLARY_CLIENT_TOKEN` the panel
|
||||
billboards with a horizon cull. When Mapillary is lit without `MAPILLARY_CLIENT_TOKEN` the panel
|
||||
reads KEY REQUIRED, says which key to add and disables the filters; a rejected
|
||||
key reads KEY REJECTED and stops tile requests until the layer goes off; a 429
|
||||
pauses them for its Retry-After.
|
||||
@@ -4644,8 +4823,9 @@ Transit is off by default, and share links use token `j`. The movement panel ord
|
||||
places Transit directly after Street Traffic and before Bike Share. Cameras and
|
||||
utilities retain their own groups. The layer polls
|
||||
registered feeds near the camera every 15 seconds, below a 3,000 km altitude
|
||||
gate. Sixteen keyless regions are available: Boston, Austin, Minneapolis–St Paul,
|
||||
Helsinki, the Netherlands, Norway, South East Queensland, and nine in
|
||||
gate. Seventeen keyless regions are available: Boston, Austin, Minneapolis–St Paul,
|
||||
Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro
|
||||
(municipal buses and the Mobi-Rio BRT, two feeds), and nine in
|
||||
Ontario — Toronto (TTC surface vehicles; the subway publishes no
|
||||
positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph,
|
||||
Burlington, Barrie and Kingston. Mode silhouettes
|
||||
@@ -4855,7 +5035,7 @@ cannot claim full acceptance.
|
||||
|
||||
**Lifecycle and proxy.** Contacts temporarily disables Transit and restores its
|
||||
prior state on exit; Space Missions refuses enablement while isolating replay
|
||||
data. Cockpit enters through Contacts and does not independently enable Transit.
|
||||
data. Cockpit does not need Contacts and does not independently enable Transit.
|
||||
The proxy fetches registered URLs only, validates redirect origins, bounds
|
||||
responses and decoding, caches successful snapshots, and backs off after failed
|
||||
refreshes. A 304 confirms contact without appending history. Missing vehicles
|
||||
@@ -5169,10 +5349,11 @@ silently demoting every later lookup for the session.
|
||||
- Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art.
|
||||
- The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset.
|
||||
- Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`.
|
||||
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
|
||||
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless navigation took it, and a toast says the mission index could not load. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. Exiting cancels the layer's own camera flights but never one navigation started. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
|
||||
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
|
||||
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
|
||||
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
|
||||
- Contacts rows and the Cockpit Contact counts name each cohort's provider: the source of the last snapshot, marked `FALLBACK` or `STALE` as its layer row would be. A poll in flight keeps the mark, where the layer row shows `LOADING`. A flight snapshot from the adsb.lol regional fallback reads `adsb.lol · FALLBACK`, not `OpenSky`.
|
||||
- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until its last finger or button lifts, even outside the canvas, unless the window loses focus or the page is hidden first; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m.
|
||||
- For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop.
|
||||
- Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff.
|
||||
@@ -5189,7 +5370,7 @@ silently demoting every later lookup for the session.
|
||||
|
||||
### Motion & Symbology Correctness (June 10, 2026)
|
||||
|
||||
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The whole fleet dead-reckons at ~12Hz (1m² write gating); aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
|
||||
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
|
||||
- **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder.
|
||||
- **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them.
|
||||
- **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode.
|
||||
@@ -5254,8 +5435,10 @@ silently demoting every later lookup for the session.
|
||||
kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list
|
||||
covering the whole country, where one _preset_ (a station's fixed view) is one camera — 806
|
||||
GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors
|
||||
on the main road spine. `CCTV_MAX_SOURCES` is a 4,000 catalog-wide ceiling shared round-robin
|
||||
across packs, so a lower global cap thins every region instead of starving the last pack.
|
||||
on the main road spine. `CCTV_MAX_SOURCES` defaults to 7,350: 4,000 shared
|
||||
round-robin slots plus 3,350 reserved Québec and World image slots.
|
||||
Reservations only use capacity above the 4,000 shared slots, so at 4,000 or
|
||||
less every pack, Québec and World included, shares the cap round-robin.
|
||||
~1,100 cameras total, all RAW PRIOR poses, stills-first. The catalog lives for 15 minutes; once
|
||||
it expires, `/api/cctv` keeps answering from it while one background refresh reloads every
|
||||
pack, so only the very first load waits for the slowest pack (a live first load took about
|
||||
@@ -5585,7 +5768,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on
|
||||
> `main` + 26 route pins; the same tree on Node 24 reports 2,295.
|
||||
|
||||
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee.
|
||||
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change; a camera the zoom leaves past the new range's limit steps down to it with the zoom, since past it the place would drop behind the Earth), and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid aLine truncated
|
||||
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
|
||||
- **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped.
|
||||
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
|
||||
@@ -5720,8 +5903,11 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total
|
||||
deadline, with Photon requests capped at six seconds each. Caller/application
|
||||
cancellation stops retries and late cache writes. Replacing a location search
|
||||
cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five
|
||||
candidates, and an unbiased retry for name mismatches. A "place, region" query
|
||||
prefers a candidate named exactly for the place whose city, county, state,
|
||||
candidates, and an unbiased retry for name mismatches. Name comparison folds
|
||||
Latin accents while preserving letters and marks in other scripts, so a
|
||||
non-Latin name spelled exactly, marks included, keeps its proximity ranking. A
|
||||
leading "the" counts: "The Hague" does not take a nearby "Hague". A "place, region" query prefers a
|
||||
candidate named exactly for the place whose city, county, state,
|
||||
country or country code (or USA/UK) contains every word of the region, and a
|
||||
settlement or administrative area over a POI when both fit. Nearby results that
|
||||
fail that check are retried unbiased. After the region checks of both passes, a
|
||||
@@ -5844,6 +6030,7 @@ request viewbox and continues searching without that geographic preference.
|
||||
|
||||
- **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked.
|
||||
- **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere.
|
||||
- **Event age filter.** One **Event age** switch in the S2 GhostMaps row applies to every selected product: 1, 3, 7 (default), 14 or 30 days, or **All** (every marker). The choice is saved separately from product selection in `localStorage` (`gev.ghostmaps.ageDays`); missing, invalid or blocked storage falls back to seven days. Age comes only from the placemark's `Date of Event` (or `EventDate`/`Date`), never from report, prose or KMZ edition dates. Undated, unparseable and future dates stay visible. ArcGIS `M/D/YYYY` dates with optional AM/PM times and ISO dates are read as UTC when no zone is given; a date without a time covers its whole UTC day, so **1d** still shows yesterday's reports. Events age out every minute while the layer is on. Narrowing the window only removes markers; widening restores them from the cached KMZ. An open card stays while its marker is shown.
|
||||
- **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub.
|
||||
- It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min.
|
||||
- Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit.
|
||||
@@ -6077,12 +6264,21 @@ apply to `public/` assets or built-preview serving.
|
||||
- Panoptic mode shows labels on ALL items (no stride skipping). Tracked/selected items keep bounding box but suppress label (skipLabel flag). Tracked items get enlarged bounding boxes (56×44 vs default 22×14).
|
||||
- The 3D aircraft toggle reveals `Proximity` and `All` modes and drives both commercial and military aircraft layers. It ships ON in `Proximity`, so the button paints lit and the mode row paints open from markup; the panel is therefore ~36 px taller than before, which Cockpit's Display/Radio strip absorbs through its existing primary-only corridor solver.
|
||||
- The host-painted tracked-target readout sits above the post-FX layer and follows tracked aircraft/satellites or selected mapped installations using each layer's display-position contract.
|
||||
- Cockpit entry is gated to the operational Contacts context bundle. The
|
||||
Context chooser must be in Contacts mode, Live Flights and Military Flights must both
|
||||
be enabled, and a civilian or military aircraft must be tracked. The visible
|
||||
Cockpit actions and the `C` shortcut use the same gate, so ordinary standalone
|
||||
flight-layer selection cannot enter a context-dependent cockpit.
|
||||
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`). Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session.
|
||||
- Cockpit entry needs only the layer of the aircraft it rides and that aircraft
|
||||
tracked: Live Flights for a civilian, Military Flights for a military aircraft
|
||||
(the tracked info's `layerId`; `cockpitEntryAllowed` in
|
||||
`src/contextModePolicy.js`). Contacts is not required, and the other aircraft
|
||||
layer, Live AIS Vessels and every other layer may be turned on or off before
|
||||
or during a session. A Context mode change still in flight blocks entry until
|
||||
it settles. Turning off the layer the view rides ends it. Riding an aircraft
|
||||
while Contacts is on adopts its layer as the user's own, as a direct selection
|
||||
does (`adoptLayerVisibility`), so Contacts ending, by its own switch or one of
|
||||
its layers going off, restores the pre-Contacts layers but keeps the ridden
|
||||
one and the view; Live Signals then drops the rows Contacts built and shows
|
||||
`CONTEXT STANDBY`. The visible Cockpit actions, the `C` shortcut and voice use
|
||||
the same gate; voice entry still opens Contacts first, as its layer-targeted
|
||||
entry navigates through Contacts.
|
||||
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leavinLine truncated
|
||||
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
|
||||
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
|
||||
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
|
||||
@@ -6256,7 +6452,26 @@ easier to meet (detection is now on more often), but does not create it.
|
||||
- Road source (`src/layers/traffic/roadModes.js`): the Street Traffic row has
|
||||
TomTom / OSM / Hybrid chips (`roadMode` param, stored in the existing layer
|
||||
state `lo` field, no new share token); `?trafficRoads=tomtom|osm|hybrid`
|
||||
overrides restored state until the user picks a chip. With no stored choice
|
||||
overrides restored state until the user picks a chip. A host may offer
|
||||
fewer modes with `createTrafficSource({ roadModes })` (a non-empty subset,
|
||||
default all three): only offered modes get chips (none for a single mode),
|
||||
and other choices from chips, restores, share links or the query are
|
||||
ignored. Without `osm` the layer never simulates: keyless or with the
|
||||
status probe unreachable, loads fail with "TomTom roads need a TomTom key"
|
||||
or "TomTom roads unavailable while the traffic service is unreachable" and
|
||||
no roads are drawn.
|
||||
- Row toggles (live mode only): after the road-source chips, Lines draws
|
||||
every road with flow as a steady draped line in its bucket color (green
|
||||
free, amber slow, red jam; jam first, then slow, then free, longest first,
|
||||
capped at 1,500; on 3D tiles under photoreal, on terrain or tiles
|
||||
otherwise), and Dots shows or hides the moving dots (hidden dots are kept,
|
||||
are not offered to detection overlays, and hold no render frames).
|
||||
Without live flow only a hidden Dots toggle is offered, so dots hidden by a
|
||||
live choice, a restore or a share link can always be shown again.
|
||||
Defaults: Lines off, Dots on. Both are `lines` / `dots` params stored in the
|
||||
layer state `lo` field (`l`, `d`). Toggling either restyles in place, with
|
||||
no refetch. The owner `jamViz` heat-line prototype is unchanged when Lines
|
||||
is off. With no stored choice
|
||||
the default is Hybrid with a TomTom key and OSM without one; share links
|
||||
without a road-source choice (including older ones) likewise use the
|
||||
installation's default (Hybrid when keyed, OpenStreetMap when keyless). Without a key
|
||||
@@ -6900,3 +7115,79 @@ the container healthy. Checks time out after 10 seconds, with a 30-second
|
||||
startup grace period and three consecutive failures before reporting unhealthy.
|
||||
This reports app HTTP availability, not individual provider health, and does not
|
||||
configure automatic restarts.
|
||||
|
||||
## Québec CCTV integration
|
||||
|
||||
The regular CCTV source loader reads `public/quebec-cameras.json` by default
|
||||
when `CCTV_QUEBEC_ENABLED` is not `0`. The pack contains 1,120 records: 680
|
||||
MTQ highway cameras, 426 Montréal OSM camera locations, and 14 scenic webcams.
|
||||
The default catalog limit is 7,350: it reserves all 1,120 Québec locations and
|
||||
2,230 World image feeds, and keeps 4,000 slots for the existing round-robin
|
||||
camera packs. Reservations only use capacity above those 4,000 slots: a lower
|
||||
explicit `CCTV_MAX_SOURCES` thins every pack round-robin, Québec and World
|
||||
included, instead of taking the shared slots.
|
||||
|
||||
The Québec 511 video is opt-in (`CCTV_QUEBEC_VIDEO_ENABLED=1`) because Québec
|
||||
511's terms for embedding its clips on another site are unconfirmed; the camera
|
||||
locations themselves are CC BY 4.0. With the flag unset, MTQ cameras show
|
||||
their location, credit and a link to the Québec 511 viewer page. With it set,
|
||||
selecting an MTQ camera opens a floating panel with a Play button and a notice
|
||||
that Québec 511 sees the viewer's IP address; the browser contacts Québec 511
|
||||
only after Play, never on selection alone (layer enable, auto-hop, a scene or
|
||||
voice), and a different camera starts unplayed. After Play, the panel plays
|
||||
the official MP4 in a native browser player and requests a second video for the
|
||||
paused map preview. The official feed is a five-second clip refreshed by
|
||||
Québec 511 every 2–4 minutes, not continuous live video; the browser player
|
||||
reloads every two minutes while the tab is visible. The
|
||||
map preview is a DOM overlay attached to the camera's coordinates and scales
|
||||
down with camera distance. Neither video is read into a canvas or Cesium texture:
|
||||
Québec 511 does not grant localhost pixel access, and its MP4 is not available
|
||||
to the app's server proxy. Montréal and scenic entries are regular CCTV camera
|
||||
locations with per-camera source-page links, but no direct frame URLs; those
|
||||
show a location label and open the official/operator page. MTQ positions carry
|
||||
CC BY 4.0 attribution, Montréal positions are attributed to OpenStreetMap
|
||||
contributors under ODbL, and scenic rows retain their position accuracy and
|
||||
provenance. Québec cameras load through the normal CCTV layer.
|
||||
|
||||
## City of Ottawa CCTV source pack
|
||||
|
||||
The CCTV catalog can add City of Ottawa traffic cameras, including the MTO
|
||||
cameras the city carries. Camera images come from the city's Traffic Camera
|
||||
Images API (`traffic.ottawa.ca/opendata/camera`), which needs a certificate
|
||||
from the city's registration form and lets one certificate and user id fetch a
|
||||
camera at most once a minute. Without `OTTAWA_CAMERA_CERTIFICATE` the pack is
|
||||
skipped with no request and one log line. The camera list is the documented
|
||||
keyless `traffic.ottawa.ca/map/service/camera` JSON. Catalog URLs carry no
|
||||
key: the frame proxy adds the certificate and the fixed user id `gev` at fetch
|
||||
time, only for a numeric camera number inside the pinned `/opendata/` path,
|
||||
and serves each camera from one shared server-side frame per minute. Inside
|
||||
that minute a camera with no cached frame gets the placeholder, never the
|
||||
Street View fallback. The feed publishes no bearing, so headings use the
|
||||
id-hash fallback at low confidence. `CCTV_OTTAWA_MAX_SOURCES` caps the pack
|
||||
(default 250, nearest downtown) and `CCTV_OTTAWA_ENABLED=0` turns it off.
|
||||
|
||||
## World camera image feeds
|
||||
|
||||
The default `world-cameras` pack reads only the checked-in, curated
|
||||
`config/cctv_sources.world-image-cameras.json` file. It adds 1,176 Oregon
|
||||
TripCheck and 1,054 MLIT river cameras. Duplicate feeds are excluded because
|
||||
DriveBC, Ottawa, Québec and Iceland (Vegagerðin) already have app packs. NZTA's
|
||||
249 rows are excluded because they were collected from a Journey Planner web
|
||||
cache whose current terms prohibit automated scraping; an authorized API feed
|
||||
or permission is required before adding them. The wider discovery export is
|
||||
not shipped with the app.
|
||||
|
||||
The app validates and pins each still-image URL to its provider directory
|
||||
before the server proxy uses it. Oregon frames refresh no faster
|
||||
than every five minutes; MLIT river frames refresh every ten minutes to reduce
|
||||
requests. Images are not persisted; the proxy reuses frames in a bounded 32 MB
|
||||
in-memory cache until their provider cadence. Set
|
||||
`CCTV_WORLD_CAMERAS_ENABLED=0` to disable the pack.
|
||||
|
||||
Only the curated image-backed rows are bundled. Camera images are fetched from
|
||||
their registered providers at request time and are not included in this data
|
||||
file. The additive `/api/cctv/sources` metadata includes provider source links,
|
||||
browser-only media links, refresh cadence, and location provenance where
|
||||
available. When MLIT serves its recognized adjustment image, `/api/cctv/frame`
|
||||
returns a God’s Eye maintenance card and `/api/cctv/health` reports the camera
|
||||
as degraded with the provider-adjustment reason.
|
||||
@@ -57,6 +57,6 @@ Consumers use the package exports, which do not import standalone application
|
||||
globals.
|
||||
|
||||
Dataset files and source/license notices remain under `src/data/local_data/`.
|
||||
Their input counts are 4,351 datacenter features and 704 dam features. Cesium may
|
||||
Their input counts are 4,352 datacenter features and 704 dam features. Cesium may
|
||||
expand multipart geometries into multiple entities; entity and stem counts are
|
||||
different measurements. This change does not refresh or relicense the datasets.
|
||||
@@ -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,traffic,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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"source":"Revos addons/Qc cams/quebec_all_cameras.geojson","count":1120,"cameras":[{"id":"4103","kind":"traffic","name":"Route 117 à la jonction de la route 101 et à la hauteur du carrefour giratoire (Rouyn-Noranda)","nameEn":"Junction of route 117 and route 101 and at roundabout (Rouyn-Noranda)","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Abitibi-Témiscamingue","lat":48.26461,"lon":-79.01808,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=4103","notes":"Live since 2025/11/26"},{"id":"3820","kind":"traffic","name":"Aut. 20 à la hauteur de la montée Industrielle-et-Commerciale","nameEn":"Aut. 20 at montée Industrielle-et-Commerciale","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.43993,"lon":-68.47784,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3820","notes":"Live since 2018/12/12"},{"id":"3821","kind":"traffic","name":"Montée Industrielle-et-Commerciale à Rimouski","nameEn":"Montée Industrielle-et-Commerciale at Rimouski","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.44639,"lon":-68.48528,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3821","notes":"Live since 2018/12/12"},{"id":"3822","kind":"traffic","name":"Route 132 à la hauteur du Bic à la jonction de l'Aut. 20","nameEn":"Route 132 at Bic at the junction of Aut. 20","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.3775,"lon":-68.68694,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3822","notes":"Live since 2018/12/12"},{"id":"3834","kind":"traffic","name":"Route 132 à la hauteur de Saint-Fabien","nameEn":"Route 132 at Saint-Fabien","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.32927,"lon":-68.80262,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3834","notes":"Live since 2019/02/07"},{"id":"3835","kind":"traffic","name":"Route 132 à la hauteur de Cap-Chat (secteur Capucins)","nameEn":"Route 132 at Cap-Chat (Capucins sector)","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":49.04153,"lon":-66.86847,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3835","notes":"Live since 2019/02/07"},{"id":"3856","kind":"traffic","name":"Route 132 à l'est de Gros-Morne","nameEn":"Route 132, east of Gros-Morne","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":49.25302,"lon":-65.48355,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3856","notes":"Live since 2019/12/06"},{"id":"3836","kind":"traffic","name":"Route 132 à la hauteur de la baie du Petit Pabos","nameEn":"Route 132 at baie du Petit Pabos","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.37216,"lon":-64.58866,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3836","notes":"Live since 2019/02/07"},{"id":"3837","kind":"traffic","name":"Route 132 à la hauteur de Bonaventure","nameEn":"Route 132 at Bonaventure","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.03448,"lon":-65.46953,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3837","Line truncated
|
||||
@@ -44,7 +44,7 @@ CCTV_NSW_ENABLED="${CCTV_NSW_ENABLED:-1}"
|
||||
CCTV_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}"
|
||||
CCTV_CALGARY_ENABLED="${CCTV_CALGARY_ENABLED:-1}"
|
||||
CCTV_CALGARY_MAX_SOURCES="${CCTV_CALGARY_MAX_SOURCES:-220}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-4000}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-7350}"
|
||||
|
||||
# Capture which provider credentials genuinely came from the parent shell
|
||||
# before this launcher resolves dotenv and Keychain fallbacks. Only names are
|
||||
|
||||
@@ -347,6 +347,7 @@
|
||||
"src/layers/flights/tracking.js",
|
||||
"src/layers/flights/rendering.js",
|
||||
"src/layers/flights/testing.js",
|
||||
"src/layers/flights/hiddenContactRotation.test.mjs",
|
||||
"src/layers/flights/evidence.js",
|
||||
"src/layers/flights/ingestion.js",
|
||||
"src/layers/flights/queries.js",
|
||||
@@ -474,6 +475,7 @@
|
||||
"src/layers/satellites/stale.test.mjs",
|
||||
"src/layers/launches/source.test.mjs",
|
||||
"src/layers/launches/ingestion.test.mjs",
|
||||
"src/layers/launches/failedEntry.test.mjs",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/layers/installations/controls.js",
|
||||
@@ -581,6 +583,7 @@
|
||||
"src/data/cctvHk.test.mjs",
|
||||
"src/data/cctvVegvesen.test.mjs",
|
||||
"src/data/cctvA22.test.mjs",
|
||||
"src/data/cctvIceland.test.mjs",
|
||||
"src/data/cctvNyc.test.mjs",
|
||||
"src/data/cctvHouston.test.mjs",
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
@@ -786,6 +789,7 @@
|
||||
"server/providers/transit.js",
|
||||
"server/providers/transitHistory.js",
|
||||
"server/providers/transitHistory.test.mjs",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/transitFeeds.test.mjs",
|
||||
"src/data/transitProxy.js",
|
||||
@@ -1126,6 +1130,8 @@
|
||||
"scripts/qa-status-badges.mjs",
|
||||
"src/dockerTrustedPeers.test.mjs",
|
||||
"src/cameraGlobeRotation.test.mjs",
|
||||
"src/cameraTilt.test.mjs",
|
||||
"src/ui/shellFacade.test.mjs",
|
||||
"src/voice/globeResetConfirmation.test.mjs",
|
||||
"src/hudDms.test.mjs",
|
||||
"src/hudSummaryGate.test.mjs",
|
||||
@@ -1267,5 +1273,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"
|
||||
]
|
||||
@@ -39,6 +39,9 @@
|
||||
"src/ui/views/panels/CockpitBriefing.vue",
|
||||
"src/ui/views/panels/CockpitContext.vue",
|
||||
"src/ui/views/panels/CockpitRoute.vue",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
"src/ui/views/panels/CockpitRollingValue.vue",
|
||||
"src/ui/views/panels/CockpitSignalList.vue",
|
||||
"src/ui/views/panels/CockpitUtilityControls.vue",
|
||||
@@ -151,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"]
|
||||
},
|
||||
@@ -160,6 +164,7 @@
|
||||
"modules": [
|
||||
"src/app/viewer.js",
|
||||
"src/app/atmosphereCompat.js",
|
||||
"src/globeInView.js",
|
||||
"src/sky/starrySky.js",
|
||||
"src/sky/stars.js",
|
||||
"src/data/bundledJson.js",
|
||||
@@ -199,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"]
|
||||
},
|
||||
@@ -343,6 +363,9 @@
|
||||
"server/providers/cctv/catalog.js",
|
||||
"server/providers/cctv/lithuania.js",
|
||||
"server/providers/cctv/taiwan.js",
|
||||
"server/providers/cctv/quebec.js",
|
||||
"server/providers/cctv/ottawa.js",
|
||||
"server/providers/cctv/worldCameras.js",
|
||||
"server/providers/cctv/constants.js",
|
||||
"server/providers/cctv/media.js",
|
||||
"server/providers/cctv/normalize.js",
|
||||
@@ -552,6 +575,7 @@
|
||||
"src/search/placeSearch.js",
|
||||
"src/search/google.js",
|
||||
"src/keylessGeocoder.js",
|
||||
"src/data/placeNames.js",
|
||||
"src/search/geospatial.js",
|
||||
"src/search/http.js",
|
||||
"src/search/defaults.js",
|
||||
@@ -785,6 +809,7 @@
|
||||
"cctv-controls": {
|
||||
"exports": ["./ui/cctv"],
|
||||
"modules": [
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/headingDegrees.js",
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
@@ -793,6 +818,7 @@
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"src/ui/quebecCctvOverlay.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/ar.json",
|
||||
@@ -812,7 +838,7 @@
|
||||
"src/ui/stores/status.js",
|
||||
"src/ui/i18n.js"
|
||||
],
|
||||
"external": ["vue", "pinia", "vue-i18n"]
|
||||
"external": ["vue", "pinia", "vue-i18n", "cesium"]
|
||||
},
|
||||
"context-controls": {
|
||||
"exports": ["./ui/context", "./ui/context/policy"],
|
||||
@@ -869,6 +895,7 @@
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
@@ -891,6 +918,7 @@
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/layerNames.js",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -983,6 +1011,8 @@
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/ui/quebecCctvOverlay.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
@@ -996,6 +1026,7 @@
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
@@ -1148,7 +1179,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"]
|
||||
},
|
||||
@@ -1166,6 +1204,7 @@
|
||||
"src/ui/styles/layers.css",
|
||||
"src/ui/styles/radio.css",
|
||||
"src/ui/styles/cctv.css",
|
||||
"src/ui/styles/quebecCctvOverlay.css",
|
||||
"src/ui/styles/scenes.css",
|
||||
"src/ui/styles/bhote-koshi.css",
|
||||
"src/ui/styles/recording.css",
|
||||
@@ -1744,6 +1783,10 @@
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/data/feedState.js",
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/ui/layerNames.js",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -2301,6 +2344,7 @@
|
||||
"src/app/layers/alprCameras.js",
|
||||
"src/app/layers/bikeshare.js",
|
||||
"src/app/layers/cctv.js",
|
||||
"src/app/layers/cyclones.js",
|
||||
"src/app/layers/earthquakes.js",
|
||||
"src/app/layers/firms.js",
|
||||
"src/app/layers/flights.js",
|
||||
@@ -2330,6 +2374,7 @@
|
||||
"src/camera.js",
|
||||
"src/cameraGroundGuard.js",
|
||||
"src/cameraVerbs.js",
|
||||
"src/globeInView.js",
|
||||
"src/cctvFocusPolicy.js",
|
||||
"src/cctvFocusRequest.js",
|
||||
"src/celestialRing.js",
|
||||
@@ -2771,6 +2816,7 @@
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"src/ui/quebecCctvOverlay.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
@@ -2954,6 +3000,7 @@
|
||||
"src/app/layers/transit.js",
|
||||
"src/data/transit.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/transitPresetStyle.js",
|
||||
"src/layers/transit/controls.js",
|
||||
@@ -3257,7 +3304,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",
|
||||
@@ -3282,6 +3354,8 @@
|
||||
"application-layer-construction": {
|
||||
"exports": ["./application/layers"],
|
||||
"modules": [
|
||||
"src/data/feedState.js",
|
||||
"src/loadingFeedback.js",
|
||||
"src/sources/adsbFields.js",
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
@@ -3316,6 +3390,7 @@
|
||||
"src/app/layers/alprCameras.js",
|
||||
"src/app/layers/bikeshare.js",
|
||||
"src/app/layers/cctv.js",
|
||||
"src/app/layers/cyclones.js",
|
||||
"src/app/layers/earthquakes.js",
|
||||
"src/app/layers/firms.js",
|
||||
"src/app/layers/flights.js",
|
||||
@@ -3658,6 +3733,9 @@
|
||||
"src/annotations/worldAnnotationRenderer.js",
|
||||
"src/cameraGroundGuard.js",
|
||||
"src/cameraVerbs.js",
|
||||
"src/globeInView.js",
|
||||
"src/scopeMaskPolicy.js",
|
||||
"src/scopeMask.js",
|
||||
"src/services/groundFloor.js",
|
||||
"src/data/routeSteps.js",
|
||||
"src/layers/directions/index.js",
|
||||
@@ -3704,6 +3782,7 @@
|
||||
"src/sources/nominatim.js",
|
||||
"src/app/layers/transit.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/transitPresetStyle.js",
|
||||
"src/layers/transit/controls.js",
|
||||
@@ -3877,7 +3956,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",
|
||||
@@ -4413,7 +4515,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": []
|
||||
},
|
||||
@@ -4465,6 +4575,7 @@
|
||||
"server/providers/transitHistory.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/sources/httpBody.js",
|
||||
@@ -4481,6 +4592,7 @@
|
||||
"modules": [
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/sources/transitHistory.js"
|
||||
@@ -4514,6 +4626,7 @@
|
||||
"src/data/terrainHeights.js",
|
||||
"src/data/transit.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/transitPresetStyle.js",
|
||||
"src/data/transitProxy.js",
|
||||
@@ -4561,7 +4674,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"]
|
||||
},
|
||||
@@ -4572,6 +4686,7 @@
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/transitPresetStyle.js",
|
||||
@@ -4629,6 +4744,7 @@
|
||||
"exports": ["./sources/transit-service"],
|
||||
"modules": [
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
@@ -4873,6 +4989,7 @@
|
||||
"src/tools/surfaces.js",
|
||||
"src/layers/transit/policy.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/layers/transit/movement.js",
|
||||
"src/data/contactPlayback.js",
|
||||
"src/data/layerSnapshot.js",
|
||||
@@ -5040,5 +5157,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": []
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@
|
||||
* (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled:
|
||||
* seen from over the Indian Ocean no pin clearly behind the planet is
|
||||
* shown; back over North America the near-side pins show again.
|
||||
* (viii) AGE — seven days is the default; shorter/longer windows and All
|
||||
* markers update the globe, and the age choice survives a reload.
|
||||
*
|
||||
* Screenshots go to qa-shots/ghostmaps-*.png (gitignored).
|
||||
*
|
||||
@@ -224,6 +226,77 @@ async function main() {
|
||||
record('LIVE: the /api/ghostmaps/events stream is open', stream);
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') });
|
||||
|
||||
const ageChips = await page.evaluate(
|
||||
(id) =>
|
||||
[
|
||||
...document.querySelectorAll(
|
||||
`[data-layer-id="${id}"] [data-segmented-id="age"] [role="radio"]`,
|
||||
),
|
||||
].map((option) => ({
|
||||
id: option.dataset.chipId,
|
||||
active: option.getAttribute('aria-checked') === 'true',
|
||||
})),
|
||||
LAYER_ID,
|
||||
);
|
||||
record(
|
||||
'AGE: seven days is the default with an All markers option',
|
||||
live.ageDays === 7 &&
|
||||
ageChips.length === 6 &&
|
||||
ageChips
|
||||
.filter((chip) => chip.active)
|
||||
.map((chip) => chip.id)
|
||||
.join() === 'age-7' &&
|
||||
ageChips.some((chip) => chip.id === 'age-all'),
|
||||
);
|
||||
const selectAge = async (days) => {
|
||||
await showRow(page);
|
||||
const selector = `[data-layer-id="${LAYER_ID}"] [data-chip-id="age-${days ?? 'all'}"]`;
|
||||
await page.waitForSelector(selector, { timeout: 15000 });
|
||||
// Dispatch through the rendered button, matching the product-chip gate.
|
||||
await page.$eval(selector, (button) => button.click());
|
||||
await page.waitForFunction(
|
||||
(id, age) => {
|
||||
const s = window.__godsEyeView.dataManager.layers
|
||||
.get(id)
|
||||
.module.getStats();
|
||||
return s.ageDays === age && !s.loading;
|
||||
},
|
||||
{ timeout: TIMEOUT_MS },
|
||||
LAYER_ID,
|
||||
days,
|
||||
);
|
||||
return stats(page);
|
||||
};
|
||||
const all = await selectAge(null);
|
||||
record(
|
||||
'AGE: All markers restores old events without duplicate data sources',
|
||||
all.count > live.count &&
|
||||
(await sources(page)).length === live.products.length,
|
||||
`${live.count} → ${all.count}`,
|
||||
);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
await enableAndSettle(page);
|
||||
const persistedAge = await stats(page);
|
||||
record(
|
||||
'AGE: All markers survives a reload',
|
||||
persistedAge.ageDays === null && persistedAge.count === all.count,
|
||||
);
|
||||
const thirty = await selectAge(30);
|
||||
const one = await selectAge(1);
|
||||
const seven = await selectAge(7);
|
||||
record(
|
||||
'AGE: counts follow the window order and seven days restores the default',
|
||||
one.count <= live.count &&
|
||||
thirty.count >= live.count &&
|
||||
thirty.count <= all.count &&
|
||||
seven.count === live.count &&
|
||||
seven.ageDays === 7,
|
||||
`1d=${one.count}, 7d=${seven.count}, 30d=${thirty.count}, all=${all.count}`,
|
||||
);
|
||||
|
||||
// ---- (ii) PICK ----------------------------------------------------------
|
||||
const target = await page.evaluate((id) => {
|
||||
const gev = window.__godsEyeView;
|
||||
|
||||
@@ -296,6 +296,22 @@ async function expandPanel(page) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A keyless bundle opens on the first keyless source with live controls. */
|
||||
async function assertKeylessDefault(page) {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
await uiUntil(page, litAlone, 'kartaview');
|
||||
const shown = await page.evaluate(() => ({
|
||||
status: document.getElementById('sl-status').textContent,
|
||||
disabled: document.getElementById('sl-controls').disabled,
|
||||
}));
|
||||
assert.notEqual(shown.status, 'KEY REQUIRED');
|
||||
assert.equal(shown.disabled, false, 'controls stay live');
|
||||
}
|
||||
|
||||
async function assertKeyRequired(page) {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||||
@@ -528,11 +544,25 @@ async function main() {
|
||||
);
|
||||
|
||||
await expandPanel(page);
|
||||
if (!configured) {
|
||||
if (!configured && litAlone(await getUI(page), 'mapillary')) {
|
||||
// A keyed bundle behind a server without the token keeps Mapillary.
|
||||
await step('keyless server: KEY REQUIRED gates the controls', () =>
|
||||
assertKeyRequired(page),
|
||||
);
|
||||
skip('no Mapillary key', 'the keyed steps');
|
||||
} else if (!configured) {
|
||||
await step('keyless server: opens on KartaView, not KEY REQUIRED', () =>
|
||||
assertKeylessDefault(page),
|
||||
);
|
||||
await step(
|
||||
'keyless server: the MAPILLARY chip explains its key instead of switching',
|
||||
async () => {
|
||||
await press(page, '#sl-provider-chips [data-chip-id="mapillary"]');
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
assert.ok(litAlone(await getUI(page), 'kartaview'));
|
||||
},
|
||||
);
|
||||
skip('no Mapillary key', 'the keyed steps');
|
||||
} else {
|
||||
await park(page);
|
||||
await step(
|
||||
@@ -751,6 +781,8 @@ async function main() {
|
||||
await park(page);
|
||||
|
||||
await fixtureStep(
|
||||
// The fixture bundle carries a token, so this covers a keyless
|
||||
// server behind a keyed bundle; the keyless default is checked above.
|
||||
'keyless server (second page): KEY REQUIRED gates the controls',
|
||||
async () => {
|
||||
const keyless = await open(
|
||||
|
||||
@@ -2321,9 +2321,11 @@ async function runBehaviorLayer() {
|
||||
`result=${JSON.stringify(r)?.slice(0, 120)} state=${JSON.stringify(contextExited)?.slice(0, 80)}`,
|
||||
);
|
||||
|
||||
// (9f) With Contacts OFF the entry gate is shut. Entry must refuse with the
|
||||
// reason rather than produce the half-entered state the operator saw: a
|
||||
// plane anchored under the camera with no HUD and no exit control.
|
||||
// (9f) Cockpit needs only the tracked aircraft's own layer, not Contacts.
|
||||
// With Contacts OFF, status must explain a shut gate without blaming
|
||||
// Contacts, and entry must either enter cleanly or refuse with the reason
|
||||
// rather than produce the half-entered state the operator saw: a plane
|
||||
// anchored under the camera with no HUD and no exit control.
|
||||
r = await run('control_cockpit', { action: 'status' });
|
||||
const gateReport = r?.state || {};
|
||||
const cockpitBeforeGate = await page.evaluate(() =>
|
||||
@@ -2331,10 +2333,13 @@ async function runBehaviorLayer() {
|
||||
);
|
||||
report(
|
||||
gateReport.active === false &&
|
||||
gateReport.entryAllowed === false &&
|
||||
gateReport.entryBlockedReason === 'contacts-inactive' &&
|
||||
(gateReport.entryAllowed === true
|
||||
? gateReport.entryBlockedReason === null
|
||||
: ['subject-layer-off', 'no-tracked-aircraft'].includes(
|
||||
gateReport.entryBlockedReason,
|
||||
)) &&
|
||||
!cockpitBeforeGate,
|
||||
'behavior: cockpit status names why entry is blocked with Contacts off',
|
||||
'behavior: cockpit status explains its gate without requiring Contacts',
|
||||
`state=${JSON.stringify(gateReport)?.slice(0, 150)}`,
|
||||
);
|
||||
|
||||
@@ -2348,12 +2353,16 @@ async function runBehaviorLayer() {
|
||||
Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active),
|
||||
);
|
||||
report(
|
||||
gated?.ok === false &&
|
||||
/contacts/i.test(gated?.error || '') &&
|
||||
!cockpitAfterGate,
|
||||
'behavior: cockpit entry with Contacts off is refused, not half-entered',
|
||||
gated?.ok === true
|
||||
? cockpitAfterGate
|
||||
: !cockpitAfterGate && !/contacts/i.test(gated?.error || ''),
|
||||
'behavior: cockpit entry with Contacts off follows the own-layer gate, never half-entered',
|
||||
`ok=${gated?.ok} error=${String(gated?.error || '').slice(0, 90)} active=${cockpitAfterGate}`,
|
||||
);
|
||||
if (cockpitAfterGate)
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView?.styleManager?.controlCockpit?.('exit'),
|
||||
);
|
||||
|
||||
await runBundledOutlineScenarios({
|
||||
page,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
+187
-5
@@ -1,4 +1,5 @@
|
||||
import { createCctvCatalog } from './cctv/catalog.js';
|
||||
import { quebec511VideoUrl, quebecBrowserVideoEnabled } from './cctv/quebec.js';
|
||||
import { normalizeFeedType, isVideoFeedType } from './cctv/normalize.js';
|
||||
import {
|
||||
buildSyntheticCctvSvg,
|
||||
@@ -18,6 +19,8 @@ import { sanitizeCctvRangeHeader } from './cctv/range.js';
|
||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
|
||||
import { createHlsPuller } from './cctv/stream.js';
|
||||
import { isMlitRiverAdjustmentImage } from './cctv/worldCameras.js';
|
||||
import { ottawaAuthorizedFrameUrl } from './cctv/ottawa.js';
|
||||
import { googleServerApiKey } from './places/google-key.js';
|
||||
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
|
||||
/**
|
||||
@@ -43,6 +46,120 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING;
|
||||
/** Live HLS strategies (see ./cctv/stream.js). Shared across dev and preview. */
|
||||
const puller = createHlsPuller();
|
||||
/**
|
||||
* Bounded transient image reuse keeps cadence-limited providers (World
|
||||
* stills, Ottawa's once-a-minute API) inside their refresh floor. The byte
|
||||
* cap is the real bound; the entry cap only limits map overhead.
|
||||
*/
|
||||
const WORLD_FRAME_CACHE_MAX_ENTRIES = 1024;
|
||||
/** Retry a failed fetch sooner than a full refresh window, never under a minute. */
|
||||
const THROTTLED_FRAME_FAILURE_BACKOFF_MS = 60 * 1000;
|
||||
const WORLD_FRAME_CACHE_MAX_ENTRY_BYTES = 2 * 1024 * 1024;
|
||||
const WORLD_FRAME_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
||||
const worldFrameCache = new Map();
|
||||
const worldFrameInflight = new Map();
|
||||
const worldFrameNextAllowedAt = new Map();
|
||||
let worldFrameCacheBytes = 0;
|
||||
|
||||
function getWorldFrameCache(cameraId) {
|
||||
const entry = worldFrameCache.get(cameraId);
|
||||
if (!entry) return null;
|
||||
worldFrameCache.delete(cameraId);
|
||||
worldFrameCache.set(cameraId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function storeWorldFrame(cameraId, image) {
|
||||
if (
|
||||
!Buffer.isBuffer(image?.body) ||
|
||||
image.body.length > WORLD_FRAME_CACHE_MAX_ENTRY_BYTES
|
||||
)
|
||||
return;
|
||||
const previous = worldFrameCache.get(cameraId);
|
||||
if (previous) worldFrameCacheBytes -= previous.body.length;
|
||||
const entry = {
|
||||
body: image.body,
|
||||
contentType: image.contentType,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
worldFrameCache.delete(cameraId);
|
||||
worldFrameCache.set(cameraId, entry);
|
||||
worldFrameCacheBytes += entry.body.length;
|
||||
while (
|
||||
worldFrameCache.size > WORLD_FRAME_CACHE_MAX_ENTRIES ||
|
||||
worldFrameCacheBytes > WORLD_FRAME_CACHE_MAX_BYTES
|
||||
) {
|
||||
const oldestId = worldFrameCache.keys().next().value;
|
||||
const oldest = worldFrameCache.get(oldestId);
|
||||
worldFrameCache.delete(oldestId);
|
||||
worldFrameCacheBytes -= oldest.body.length;
|
||||
}
|
||||
}
|
||||
|
||||
function setNextAllowedAt(cameraId, at) {
|
||||
worldFrameNextAllowedAt.delete(cameraId);
|
||||
worldFrameNextAllowedAt.set(cameraId, at);
|
||||
while (worldFrameNextAllowedAt.size > 4096) {
|
||||
worldFrameNextAllowedAt.delete(
|
||||
worldFrameNextAllowedAt.keys().next().value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a frame from a provider with a per-camera refresh floor. Inside the
|
||||
* floor the last frame is reused; with no frame to reuse the caller gets
|
||||
* `{ throttled: true }` and serves a placeholder, so a cadence-limited camera
|
||||
* never falls through to the key-bearing Street View fallback. A failed
|
||||
* fetch backs off for a minute (capped at the floor) instead of a full
|
||||
* window.
|
||||
*/
|
||||
async function fetchThrottledFrame(source, cameraId, frameUrl) {
|
||||
const cached = getWorldFrameCache(cameraId);
|
||||
const rawRefreshMs = Number(source?.frameRefreshMs);
|
||||
const refreshMs =
|
||||
Number.isFinite(rawRefreshMs) && rawRefreshMs > 0
|
||||
? rawRefreshMs
|
||||
: 5 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
if (cached && now - cached.fetchedAt < refreshMs) {
|
||||
return { ok: true, ...cached, cached: true };
|
||||
}
|
||||
|
||||
const inflight = worldFrameInflight.get(cameraId);
|
||||
if (inflight) return inflight;
|
||||
const stale = cached
|
||||
? {
|
||||
ok: true,
|
||||
body: cached.body,
|
||||
contentType: cached.contentType,
|
||||
stale: true,
|
||||
}
|
||||
: null;
|
||||
const nextAllowedAt = worldFrameNextAllowedAt.get(cameraId) || 0;
|
||||
if (now < nextAllowedAt) return stale || { ok: false, throttled: true };
|
||||
if (!frameUrl) return stale;
|
||||
setNextAllowedAt(cameraId, now + refreshMs);
|
||||
let request;
|
||||
request = fetchCctvImageFromUpstream(frameUrl)
|
||||
.then((image) => {
|
||||
if (image?.ok) {
|
||||
storeWorldFrame(cameraId, image);
|
||||
return image;
|
||||
}
|
||||
setNextAllowedAt(
|
||||
cameraId,
|
||||
Date.now() + Math.min(refreshMs, THROTTLED_FRAME_FAILURE_BACKOFF_MS),
|
||||
);
|
||||
return stale || image;
|
||||
})
|
||||
.finally(() => {
|
||||
if (worldFrameInflight.get(cameraId) === request)
|
||||
worldFrameInflight.delete(cameraId);
|
||||
});
|
||||
worldFrameInflight.set(cameraId, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Update the health entry for a camera, evicting the oldest entry if at capacity. */
|
||||
const setHealth = (cameraId, patch) => {
|
||||
@@ -153,6 +270,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
);
|
||||
|
||||
if (url.pathname === '/sources') {
|
||||
const quebecVideo = quebecBrowserVideoEnabled();
|
||||
const body = {
|
||||
trimmedPacks: getCctvSources.trimmedPacks?.(sources) ?? [],
|
||||
sources: sources.map((source) => ({
|
||||
@@ -164,6 +282,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
provider: source.provider,
|
||||
lat: source.lat,
|
||||
lon: source.lon,
|
||||
frameRefreshMs: source.frameRefreshMs,
|
||||
headingDeg: source.headingDeg,
|
||||
headingConfidence: source.headingConfidence || '',
|
||||
pitchDeg: source.pitchDeg,
|
||||
@@ -174,6 +293,13 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
feedType: normalizeFeedType(source.feedType),
|
||||
sourceKind:
|
||||
source.sourceKind || (source.url ? 'configured' : 'fallback'),
|
||||
sourceUrl: source.sourceUrl || '',
|
||||
browserMediaUrl:
|
||||
quebecVideo && source.sourceKind === 'quebec-511-browser'
|
||||
? quebec511VideoUrl(source.id)
|
||||
: '',
|
||||
accuracyM: source.accuracyM,
|
||||
positionSource: source.positionSource || '',
|
||||
poseSource: source.poseSource,
|
||||
model: source.model || '',
|
||||
manufacturer: source.manufacturer || '',
|
||||
@@ -526,15 +652,71 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
: '');
|
||||
|
||||
const upstreamImage =
|
||||
source?.sourceKind === 'txdot-its'
|
||||
? await fetchTxdotSnapshot(upstreamCandidate)
|
||||
: await fetchCctvImageFromUpstream(upstreamCandidate);
|
||||
source?.pack === 'world-cameras'
|
||||
? await fetchThrottledFrame(source, cameraId, upstreamCandidate)
|
||||
: source?.sourceKind === 'ottawa-traffic'
|
||||
? await fetchThrottledFrame(
|
||||
source,
|
||||
cameraId,
|
||||
ottawaAuthorizedFrameUrl(upstreamCandidate),
|
||||
)
|
||||
: source?.sourceKind === 'txdot-its'
|
||||
? await fetchTxdotSnapshot(upstreamCandidate)
|
||||
: await fetchCctvImageFromUpstream(upstreamCandidate, {
|
||||
allowMjpeg: source?.sourceKind === 'taiwan-highway-motc',
|
||||
});
|
||||
if (
|
||||
source?.sourceKind === 'mlit-river' &&
|
||||
isMlitRiverAdjustmentImage(upstreamImage)
|
||||
) {
|
||||
const svg = buildSyntheticCctvSvg({
|
||||
cameraId,
|
||||
label,
|
||||
city,
|
||||
status: 'CAMERA UNDER ADJUSTMENT',
|
||||
});
|
||||
setHealth(cameraId, {
|
||||
status: 'degraded',
|
||||
sourceKind: 'snapshot',
|
||||
label: source.provider || 'MLIT river cameras',
|
||||
message: 'Provider camera under adjustment',
|
||||
});
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-CCTV-Source': 'provider-maintenance',
|
||||
});
|
||||
res.end(svg);
|
||||
return;
|
||||
}
|
||||
if (upstreamImage?.throttled) {
|
||||
// Inside the provider's refresh floor with no frame to reuse: a
|
||||
// placeholder, not Street View, and no health change.
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-CCTV-Source': 'synthetic',
|
||||
});
|
||||
res.end(
|
||||
buildSyntheticCctvSvg({
|
||||
cameraId,
|
||||
label,
|
||||
city,
|
||||
status: 'WAITING FOR NEXT FRAME',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (upstreamImage?.ok) {
|
||||
setHealth(cameraId, {
|
||||
status: 'ok',
|
||||
status: upstreamImage.stale ? 'degraded' : 'ok',
|
||||
sourceKind: 'snapshot',
|
||||
label: source?.provider || 'Configured source',
|
||||
message: 'Upstream snapshot active',
|
||||
message: upstreamImage.stale
|
||||
? 'Serving last snapshot; refresh failed'
|
||||
: upstreamImage.cached
|
||||
? 'Cached snapshot active'
|
||||
: 'Upstream snapshot active',
|
||||
});
|
||||
res.writeHead(200, {
|
||||
'Content-Type': upstreamImage.contentType,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
} from './constants.js';
|
||||
|
||||
@@ -23,16 +24,27 @@ export function resolveCatalogCap(raw) {
|
||||
* exceed the catalog cap, the cap is filled ROUND-ROBIN — one camera from
|
||||
* each pack per turn, in each pack's own order — so a lowered
|
||||
* CCTV_MAX_SOURCES thins every region a little instead of deleting whichever
|
||||
* pack happened to be appended last. Duplicate ids resolve last-pack-wins so
|
||||
* pack happened to be appended last. A pack with a `reservedCount` keeps up to
|
||||
* that many rows only from the capacity ABOVE the shared floor, so a
|
||||
* reservation adds room and never shrinks the other packs' round-robin
|
||||
* share; at or below the floor reserved packs are ordinary lanes. Duplicate
|
||||
* ids resolve last-pack-wins so
|
||||
* file/env overrides keep replacing live records; the record is then counted
|
||||
* against the pack that won it.
|
||||
*
|
||||
* @param {Array<{name:string, sources:Array<object>}>} packs - Normalized
|
||||
* packs in merge order (earlier packs lose duplicate ids to later ones).
|
||||
* @param {number} maxCount - Catalog cap.
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.sharedFloor=CCTV_SHARED_SOURCE_SLOTS] - Slots kept
|
||||
* for the round-robin before reservations apply.
|
||||
* @returns {{sources:Array<object>, packs:Array<{name:string, offered:number, kept:number}>}}
|
||||
*/
|
||||
export function allocateSourceCap(packs, maxCount) {
|
||||
export function allocateSourceCap(
|
||||
packs,
|
||||
maxCount,
|
||||
{ sharedFloor = CCTV_SHARED_SOURCE_SLOTS } = {},
|
||||
) {
|
||||
const owner = new Map();
|
||||
for (const pack of packs) {
|
||||
for (const source of pack.sources) {
|
||||
@@ -40,30 +52,53 @@ export function allocateSourceCap(packs, maxCount) {
|
||||
owner.set(source.id, { pack: pack.name, source });
|
||||
}
|
||||
}
|
||||
const lanes = packs.map((pack) => ({
|
||||
name: pack.name,
|
||||
const lanes = packs.map((pack) => {
|
||||
// Only the record that WON its id (the last occurrence anywhere, so also
|
||||
// the last within this pack) takes a lane slot; earlier duplicates never
|
||||
// count against the cap.
|
||||
queue: pack.sources.filter(
|
||||
const queue = pack.sources.filter(
|
||||
(source) => source?.id && owner.get(source.id)?.source === source,
|
||||
),
|
||||
next: 0,
|
||||
kept: 0,
|
||||
}));
|
||||
);
|
||||
return {
|
||||
name: pack.name,
|
||||
queue,
|
||||
reserved: Math.max(
|
||||
0,
|
||||
Math.min(queue.length, Math.floor(Number(pack.reservedCount) || 0)),
|
||||
),
|
||||
next: 0,
|
||||
kept: 0,
|
||||
};
|
||||
});
|
||||
const total = lanes.reduce((sum, lane) => sum + lane.queue.length, 0);
|
||||
const limit = Math.min(total, Math.max(0, Math.floor(maxCount)));
|
||||
|
||||
const sources = [];
|
||||
const reservedSources = [];
|
||||
if (limit >= total) {
|
||||
for (const lane of lanes) {
|
||||
sources.push(...lane.queue);
|
||||
lane.kept = lane.queue.length;
|
||||
}
|
||||
} else {
|
||||
while (sources.length < limit) {
|
||||
// Reserved packs draw only on the capacity above the shared floor, then
|
||||
// every lane (reserved ones included) shares the rest round-robin.
|
||||
const reserveBudget = Math.max(0, limit - Math.max(0, sharedFloor));
|
||||
while (reservedSources.length < reserveBudget) {
|
||||
let tookReserved = false;
|
||||
for (const lane of lanes) {
|
||||
if (sources.length >= limit) break;
|
||||
if (reservedSources.length >= reserveBudget) break;
|
||||
if (lane.kept >= lane.reserved) continue;
|
||||
reservedSources.push(lane.queue[lane.next]);
|
||||
lane.next += 1;
|
||||
lane.kept += 1;
|
||||
tookReserved = true;
|
||||
}
|
||||
if (!tookReserved) break;
|
||||
}
|
||||
while (sources.length + reservedSources.length < limit) {
|
||||
for (const lane of lanes) {
|
||||
if (sources.length + reservedSources.length >= limit) break;
|
||||
if (lane.next >= lane.queue.length) continue;
|
||||
sources.push(lane.queue[lane.next]);
|
||||
lane.next += 1;
|
||||
@@ -72,7 +107,9 @@ export function allocateSourceCap(packs, maxCount) {
|
||||
}
|
||||
}
|
||||
return {
|
||||
sources,
|
||||
// Keep the shared round-robin sequence first so the default active camera
|
||||
// remains in its established region; guaranteed regional rows follow it.
|
||||
sources: [...sources, ...reservedSources],
|
||||
packs: lanes.map((lane) => ({
|
||||
name: lane.name,
|
||||
offered: lane.queue.length,
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js';
|
||||
import {
|
||||
DEFAULT_CCTV_SOURCE_FILE,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
} from './constants.js';
|
||||
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
|
||||
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
|
||||
import { cameraRegion, normalizeSourceItem } from './normalize.js';
|
||||
import { loadLithuaniaSources } from './lithuania.js';
|
||||
import { loadOttawaSourcesFromOpenData } from './ottawa.js';
|
||||
import { loadQuebecSources, QUEBEC_CAMERA_SOURCE_COUNT } from './quebec.js';
|
||||
import { loadTaiwanSources } from './taiwan.js';
|
||||
import { loadWorldCameraSources } from './worldCameras.js';
|
||||
import {
|
||||
loadAustinSourcesFromOpenData,
|
||||
loadCaltransSourcesFromOpenData,
|
||||
@@ -20,6 +27,7 @@ import {
|
||||
loadNswSourcesFromOpenData,
|
||||
loadCalgarySourcesFromOpenData,
|
||||
loadCataloniaSourcesFromOpenData,
|
||||
loadIcelandSourcesFromOpenData,
|
||||
loadHkSourcesFromOpenData,
|
||||
loadDelDOTSourcesFromOpenData,
|
||||
loadKingCountySourcesFromOpenData,
|
||||
@@ -75,6 +83,11 @@ const LIVE_PACKS = [
|
||||
enabled: () => envEnabled('CCTV_DRIVEBC_ENABLED'),
|
||||
load: loadDriveBcSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'ottawa',
|
||||
enabled: () => envEnabled('CCTV_OTTAWA_ENABLED'),
|
||||
load: loadOttawaSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'txdot',
|
||||
enabled: () => envEnabled('CCTV_TXDOT_ENABLED'),
|
||||
@@ -110,6 +123,11 @@ const LIVE_PACKS = [
|
||||
enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'),
|
||||
load: loadCataloniaSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'iceland',
|
||||
enabled: () => envEnabled('CCTV_ICELAND_ENABLED'),
|
||||
load: loadIcelandSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'hk',
|
||||
enabled: () => envEnabled('CCTV_HK_ENABLED'),
|
||||
@@ -172,6 +190,18 @@ const LIVE_PACKS = [
|
||||
enabled: () => envOptIn('CCTV_HOUSTON_ENABLED'),
|
||||
load: loadHoustonSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'quebec',
|
||||
enabled: () => envEnabled('CCTV_QUEBEC_ENABLED'),
|
||||
load: loadQuebecSources,
|
||||
reservedCount: QUEBEC_CAMERA_SOURCE_COUNT,
|
||||
},
|
||||
{
|
||||
name: 'world-cameras',
|
||||
enabled: () => envEnabled('CCTV_WORLD_CAMERAS_ENABLED'),
|
||||
load: loadWorldCameraSources,
|
||||
reservedCount: WORLD_CCTV_SOURCE_COUNT,
|
||||
},
|
||||
{
|
||||
name: 'a22',
|
||||
enabled: () => envEnabled('CCTV_A22_ENABLED'),
|
||||
@@ -316,7 +346,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
: [];
|
||||
// Live packs first so file/env overrides win on duplicate IDs; each pack
|
||||
// keeps its own priority order and the catalog cap is shared fairly.
|
||||
const normalizePack = (name, items) => ({
|
||||
const normalizePack = (name, items, reservedCount = 0) => ({
|
||||
name,
|
||||
sources: items
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
@@ -326,6 +356,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
? items.available
|
||||
: items.length,
|
||||
region: items.region ?? null,
|
||||
reservedCount,
|
||||
});
|
||||
const packs = [
|
||||
...LIVE_PACKS.map((pack, index) =>
|
||||
@@ -334,6 +365,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
liveResults[index]?.status === 'fulfilled'
|
||||
? liveResults[index].value
|
||||
: [],
|
||||
pack.reservedCount,
|
||||
),
|
||||
),
|
||||
normalizePack('file', fromFile),
|
||||
|
||||
@@ -5,15 +5,18 @@ export const DEFAULT_AUSTIN_ROWS_URL =
|
||||
/** Default cap on Austin cameras after distance-based prioritization. */
|
||||
export const DEFAULT_AUSTIN_MAX_SOURCES = 250;
|
||||
/**
|
||||
* Catalog-wide safety ceiling on served cameras. Each pack already caps
|
||||
* itself (nearest-to-anchor first); this bound only matters when the packs
|
||||
* together exceed it, and it is then filled round-robin across packs (see
|
||||
* cap.js) so no region is silently dropped. The default per-pack caps
|
||||
* together can exceed it, so a default install may be thinned evenly here.
|
||||
* Slots every pack shares round-robin before any reservation applies. A
|
||||
* reserved pack (Québec, World) only takes capacity above this floor, so a
|
||||
* cap at or below it thins every region evenly (see cap.js).
|
||||
*/
|
||||
export const DEFAULT_CCTV_MAX_SOURCES = 4000;
|
||||
export const CCTV_SHARED_SOURCE_SLOTS = 4000;
|
||||
/**
|
||||
* Catalog-wide default: the 4,000 shared slots plus room reserved for the
|
||||
* 1,120 Québec and 2,230 World image locations.
|
||||
*/
|
||||
export const DEFAULT_CCTV_MAX_SOURCES = 7350;
|
||||
/** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */
|
||||
export const CCTV_MAX_SOURCES_CEILING = 5000;
|
||||
export const CCTV_MAX_SOURCES_CEILING = 8000;
|
||||
/** Reference point for Austin camera prioritization (Congress & 6th). */
|
||||
export const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 };
|
||||
/** Caltrans CCTV: one JSON feed per district, identical schema statewide. */
|
||||
@@ -197,6 +200,39 @@ export const DRIVEBC_ANCHORS = [
|
||||
{ lat: 49.2827, lon: -123.1207 }, // Vancouver
|
||||
{ lat: 48.4284, lon: -123.3656 }, // Victoria
|
||||
];
|
||||
/** City of Ottawa traffic cameras: documented JSON list (keyless). */
|
||||
export const OTTAWA_CAMERAS_URL =
|
||||
'https://traffic.ottawa.ca/map/service/camera';
|
||||
/**
|
||||
* Keyless canonical frame URL for one camera number. The city's Traffic Camera
|
||||
* Images API needs a certificate (OTTAWA_CAMERA_CERTIFICATE), which the frame
|
||||
* proxy adds at request time so it never appears in a catalog response.
|
||||
*/
|
||||
export const OTTAWA_IMAGE_URL = (cameraNumber) =>
|
||||
`https://traffic.ottawa.ca/opendata/camera?c=${encodeURIComponent(cameraNumber)}`;
|
||||
/** Pinned frame directory, for redirect containment in the media proxy. */
|
||||
export const OTTAWA_IMAGE_ORIGIN = 'https://traffic.ottawa.ca/opendata/';
|
||||
/** The API lets one certificate and user id fetch a camera once a minute. */
|
||||
export const OTTAWA_FRAME_REFRESH_MS = 60 * 1000;
|
||||
/** Alphanumeric user id this server sends with its certificate. */
|
||||
export const OTTAWA_API_USER_ID = 'gev';
|
||||
export const DEFAULT_OTTAWA_MAX_SOURCES = 250;
|
||||
/** Prioritization anchor: downtown Ottawa (Parliament Hill). */
|
||||
export const OTTAWA_ANCHORS = [{ lat: 45.4215, lon: -75.6972 }];
|
||||
/**
|
||||
* Taiwan Highway Bureau MJPEG servers. Each camera is one root path segment,
|
||||
* so the pin is the host root: a redirect must stay on the same server.
|
||||
*/
|
||||
export const TAIWAN_HIGHWAY_IMAGE_ORIGINS = Object.freeze(
|
||||
[1, 2, 3, 4, 5, 6, 7, 8].map((n) => `https://cctv-ss0${n}.thb.gov.tw/`),
|
||||
);
|
||||
/** Direct still-image directories from the World cams add-on's eligible packs. */
|
||||
export const TRIPCHECK_IMAGE_ORIGIN =
|
||||
'https://www.tripcheck.com/roadcams/cams/';
|
||||
export const MLIT_RIVER_IMAGE_ORIGIN = 'https://cam.river.go.jp/cam/now/';
|
||||
export const DEFAULT_WORLD_CCTV_SOURCE_FILE =
|
||||
'config/cctv_sources.world-image-cameras.json';
|
||||
export const WORLD_CCTV_SOURCE_COUNT = 2230;
|
||||
/** Tallinn intersection cameras: curated catalog + public stills on ristmikud.tallinn.ee. */
|
||||
export const DEFAULT_TALLINN_SOURCE_FILE = 'config/cctv_sources.tallinn.json';
|
||||
export const DEFAULT_TALLINN_MAX_SOURCES = 255;
|
||||
@@ -309,6 +345,37 @@ export const CATALONIA_ANCHORS = [
|
||||
{ lat: 41.5636, lon: 2.0111 }, // Terrassa
|
||||
];
|
||||
|
||||
/**
|
||||
* Icelandic Road and Coastal Administration (Vegagerðin, IRCA) road cameras:
|
||||
* one keyless JSON list, one row per camera view (about 500 views at roughly
|
||||
* 170 stations, verified 2026-11-06). Frames are stills on the IRCA host, each
|
||||
* rewritten by the publisher every few minutes. Camera images are published
|
||||
* under CC BY 4.0 (IRCA terms of use, "Open data" section).
|
||||
*/
|
||||
export const ICELAND_CAMERAS_URL =
|
||||
'https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1';
|
||||
/** The only directory Iceland camera frames may come from. */
|
||||
export const ICELAND_IMAGE_ORIGIN =
|
||||
'https://www.vegagerdin.is/vgdata/vefmyndavelar/';
|
||||
/** Byte ceiling for the Iceland catalog read (~150 KB observed 2026-11-06). */
|
||||
export const ICELAND_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
||||
/** Ground-elevation prior in metres: the feed carries none, and the country
|
||||
* runs from sea level to highland passes. The client's ground snap corrects
|
||||
* this on 3D-tile stacks. */
|
||||
export const ICELAND_GROUND_ELEVATION_M = 60;
|
||||
/** The feed lists about 500 views; the default keeps half so the shared
|
||||
* catalog cap still leaves room for denser regions. */
|
||||
export const DEFAULT_ICELAND_MAX_SOURCES = 250;
|
||||
/** Prioritization anchors: Reykjavík and the regional centres, so a cap keeps
|
||||
* every region's nearest cameras instead of only the capital's. */
|
||||
export const ICELAND_ANCHORS = [
|
||||
{ lat: 64.1466, lon: -21.9426 }, // Reykjavík
|
||||
{ lat: 65.6885, lon: -18.1262 }, // Akureyri
|
||||
{ lat: 65.2639, lon: -14.3948 }, // Egilsstaðir
|
||||
{ lat: 66.0749, lon: -23.1351 }, // Ísafjörður
|
||||
{ lat: 63.9331, lon: -20.9971 }, // Selfoss
|
||||
];
|
||||
|
||||
/** Hong Kong Transport Department traffic snapshot cameras: keyless English
|
||||
* locations XML on DATA.GOV.HK; frames are stills on the TD CCTV host. */
|
||||
export const DEFAULT_HK_CAMERAS_URL =
|
||||
|
||||
@@ -10,10 +10,15 @@ import {
|
||||
CCTV_MEDIA_MAX_BODY_BYTES,
|
||||
HK_IMAGE_ORIGIN,
|
||||
KING_COUNTY_IMAGE_ORIGIN,
|
||||
MLIT_RIVER_IMAGE_ORIGIN,
|
||||
TRIPCHECK_IMAGE_ORIGIN,
|
||||
NSW_IMAGE_ORIGIN,
|
||||
NSW_IMAGE_USER_AGENT,
|
||||
OTTAWA_IMAGE_ORIGIN,
|
||||
TALLINN_IMAGE_ORIGIN,
|
||||
TARKTEE_IMAGE_ORIGIN,
|
||||
ICELAND_IMAGE_ORIGIN,
|
||||
TAIWAN_HIGHWAY_IMAGE_ORIGINS,
|
||||
TFL_IMAGE_ORIGIN,
|
||||
WARENDORF_IMAGE_ORIGINS,
|
||||
WSDOT_IMAGE_ORIGIN,
|
||||
@@ -523,13 +528,18 @@ for (const prefix of [
|
||||
TFL_IMAGE_ORIGIN,
|
||||
TALLINN_IMAGE_ORIGIN,
|
||||
TARKTEE_IMAGE_ORIGIN,
|
||||
ICELAND_IMAGE_ORIGIN,
|
||||
...WARENDORF_IMAGE_ORIGINS,
|
||||
NSW_IMAGE_ORIGIN,
|
||||
OTTAWA_IMAGE_ORIGIN,
|
||||
CALGARY_IMAGE_ORIGIN,
|
||||
HK_IMAGE_ORIGIN,
|
||||
KING_COUNTY_IMAGE_ORIGIN,
|
||||
WSDOT_IMAGE_ORIGIN,
|
||||
A22_IMAGE_ORIGIN,
|
||||
TRIPCHECK_IMAGE_ORIGIN,
|
||||
MLIT_RIVER_IMAGE_ORIGIN,
|
||||
...TAIWAN_HIGHWAY_IMAGE_ORIGINS,
|
||||
]) {
|
||||
const host = new URL(prefix).hostname;
|
||||
CCTV_PINNED_PREFIXES_BY_HOST.set(host, [
|
||||
@@ -621,6 +631,216 @@ export function cctvUpstreamUserAgent(url) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Largest single JPEG the MJPEG first-frame reader will buffer. */
|
||||
export const CCTV_MJPEG_FRAME_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Read one bounded JPEG part from an open MJPEG response, then stop the stream.
|
||||
*
|
||||
* The first part may follow a CRLF preamble, and its delimiter may be written
|
||||
* as `--<boundary>` or, when the declared boundary already starts with `--`,
|
||||
* as the boundary itself. A part with Content-Length is read to that length;
|
||||
* a part without one ends at the next delimiter, so a JPEG end marker inside
|
||||
* the image (an EXIF thumbnail) never truncates it. Bytes are copied once
|
||||
* into a preallocated buffer and every search resumes where it stopped, so a
|
||||
* trickling upstream costs linear time.
|
||||
*/
|
||||
async function readFirstMjpegFrame(upstream, maxBytes) {
|
||||
const mediaType = cctvLiveMediaType(
|
||||
'mjpeg',
|
||||
upstream.headers.get('content-type'),
|
||||
);
|
||||
const boundary = parseMediaType(mediaType)?.params.get('boundary') || '';
|
||||
const frameMax = Math.min(maxBytes, CCTV_MJPEG_FRAME_MAX_BYTES);
|
||||
if (
|
||||
!boundary ||
|
||||
!upstream.body ||
|
||||
!Number.isSafeInteger(frameMax) ||
|
||||
frameMax < 4
|
||||
) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const delimiters = [Buffer.from('--' + boundary, 'latin1')];
|
||||
if (boundary.startsWith('--') && boundary.length > 2)
|
||||
delimiters.push(Buffer.from(boundary, 'latin1'));
|
||||
const lineEnd = Buffer.from('\r\n', 'ascii');
|
||||
const headerTerminator = Buffer.from('\r\n\r\n', 'ascii');
|
||||
const jpegStart = Buffer.from([0xff, 0xd8]);
|
||||
const jpegEnd = Buffer.from([0xff, 0xd9]);
|
||||
const maxPreambleBytes = 64;
|
||||
const maxHeaderBytes = 8 * 1024;
|
||||
const maxBufferedBytes =
|
||||
maxPreambleBytes +
|
||||
delimiters[0].length +
|
||||
lineEnd.length +
|
||||
maxHeaderBytes +
|
||||
headerTerminator.length +
|
||||
frameMax +
|
||||
lineEnd.length +
|
||||
delimiters[0].length;
|
||||
const buffer = Buffer.allocUnsafe(maxBufferedBytes);
|
||||
let length = 0;
|
||||
let reader;
|
||||
let headerStart = -1;
|
||||
let delimiter = null;
|
||||
let headerScanFrom = 0;
|
||||
let bodyStart = -1;
|
||||
let declaredLength = null;
|
||||
let boundaryScanFrom = -1;
|
||||
|
||||
/** Match the opening delimiter after an optional CRLF preamble. */
|
||||
function findOpening() {
|
||||
let at = 0;
|
||||
while (at < length && (buffer[at] === 0x0d || buffer[at] === 0x0a)) at++;
|
||||
if (at > maxPreambleBytes) return { failed: true };
|
||||
for (const candidate of delimiters) {
|
||||
const line = Buffer.concat([candidate, lineEnd]);
|
||||
const need = at + line.length;
|
||||
if (length < need) {
|
||||
const seen = buffer.subarray(at, length);
|
||||
if (line.subarray(0, seen.length).equals(seen))
|
||||
return { pending: true };
|
||||
continue;
|
||||
}
|
||||
if (buffer.subarray(at, need).equals(line))
|
||||
return { start: need, delimiter: candidate };
|
||||
}
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
/** Parse the part headers; false rejects the stream. */
|
||||
function parseHeaders(headerEnd) {
|
||||
const headerText = buffer
|
||||
.subarray(headerStart, headerEnd)
|
||||
.toString('latin1');
|
||||
const headers = new Map();
|
||||
for (const line of headerText.split('\r\n')) {
|
||||
const colon = line.indexOf(':');
|
||||
if (colon <= 0) return false;
|
||||
const name = line.slice(0, colon).trim().toLowerCase();
|
||||
if (!name || headers.has(name)) return false;
|
||||
headers.set(name, line.slice(colon + 1).trim());
|
||||
}
|
||||
if (cctvRasterImageType(headers.get('content-type')) !== 'image/jpeg')
|
||||
return false;
|
||||
const declared = headers.get('content-length');
|
||||
if (declared !== undefined) {
|
||||
if (!/^\d+$/.test(declared)) return false;
|
||||
declaredLength = Number(declared);
|
||||
if (
|
||||
!Number.isSafeInteger(declaredLength) ||
|
||||
declaredLength < jpegStart.length + jpegEnd.length ||
|
||||
declaredLength > frameMax
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A complete JPEG body, or null. */
|
||||
function asJpeg(frame) {
|
||||
return frame.length >= jpegStart.length + jpegEnd.length &&
|
||||
frame.subarray(0, jpegStart.length).equals(jpegStart) &&
|
||||
frame.subarray(-jpegEnd.length).equals(jpegEnd)
|
||||
? Buffer.from(frame)
|
||||
: null;
|
||||
}
|
||||
|
||||
try {
|
||||
reader = upstream.body.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return null;
|
||||
|
||||
const chunk = Buffer.from(value);
|
||||
const room = maxBufferedBytes - length;
|
||||
const included = Math.min(room, chunk.length);
|
||||
chunk.copy(buffer, length, 0, included);
|
||||
length += included;
|
||||
const overflow = included < chunk.length;
|
||||
|
||||
if (headerStart < 0) {
|
||||
const opening = findOpening();
|
||||
if (opening.failed) return null;
|
||||
if (opening.pending) {
|
||||
if (overflow) return null;
|
||||
continue;
|
||||
}
|
||||
headerStart = opening.start;
|
||||
delimiter = opening.delimiter;
|
||||
headerScanFrom = headerStart;
|
||||
}
|
||||
|
||||
if (bodyStart < 0) {
|
||||
const headerEnd = buffer
|
||||
.subarray(0, length)
|
||||
.indexOf(headerTerminator, headerScanFrom);
|
||||
if (headerEnd < 0) {
|
||||
if (length - headerStart > maxHeaderBytes || overflow) return null;
|
||||
headerScanFrom = Math.max(
|
||||
headerStart,
|
||||
length - headerTerminator.length + 1,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (headerEnd - headerStart > maxHeaderBytes) return null;
|
||||
if (!parseHeaders(headerEnd)) return null;
|
||||
bodyStart = headerEnd + headerTerminator.length;
|
||||
boundaryScanFrom = bodyStart;
|
||||
}
|
||||
|
||||
const available = length - bodyStart;
|
||||
if (available >= jpegStart.length) {
|
||||
if (
|
||||
!buffer
|
||||
.subarray(bodyStart, bodyStart + jpegStart.length)
|
||||
.equals(jpegStart)
|
||||
)
|
||||
return null;
|
||||
}
|
||||
const nextDelimiter = Buffer.concat([lineEnd, delimiter]);
|
||||
const nextPart = buffer
|
||||
.subarray(0, length)
|
||||
.indexOf(nextDelimiter, boundaryScanFrom);
|
||||
if (declaredLength !== null) {
|
||||
if (nextPart >= bodyStart && nextPart - bodyStart < declaredLength)
|
||||
return null;
|
||||
if (available >= declaredLength)
|
||||
return asJpeg(buffer.subarray(bodyStart, bodyStart + declaredLength));
|
||||
} else if (nextPart >= bodyStart) {
|
||||
const frame = buffer.subarray(bodyStart, nextPart);
|
||||
return frame.length > frameMax ? null : asJpeg(frame);
|
||||
} else if (available > frameMax) {
|
||||
return null;
|
||||
}
|
||||
if (nextPart < 0)
|
||||
boundaryScanFrom = Math.max(
|
||||
bodyStart,
|
||||
length - nextDelimiter.length + 1,
|
||||
);
|
||||
if (overflow) return null;
|
||||
}
|
||||
} finally {
|
||||
if (reader) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
/* already closed or aborted */
|
||||
}
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Fetch one upstream CCTV image within the frame-refresh budget.
|
||||
*
|
||||
@@ -633,6 +853,10 @@ export function cctvUpstreamUserAgent(url) {
|
||||
* @param {typeof fetch} [options.fetchImpl=fetch] - Fetch implementation.
|
||||
* @param {number} [options.timeoutMs=CCTV_FRAME_FETCH_TIMEOUT_MS] - Abort timeout.
|
||||
* @param {number} [options.maxBytes=CCTV_FRAME_MAX_BODY_BYTES] - Snapshot byte cap.
|
||||
* @param {boolean} [options.allowMjpeg=false] - Accept a multipart MJPEG
|
||||
* answer and keep its first JPEG. Only sources registered as MJPEG stills
|
||||
* (the Taiwan Highway Bureau feeds) set it; any other host answering
|
||||
* multipart falls through to the next fallback.
|
||||
* @returns {Promise<{ok:true,body:Buffer,contentType:string}|null>}
|
||||
*/
|
||||
export async function fetchCctvImageFromUpstream(
|
||||
@@ -641,6 +865,7 @@ export async function fetchCctvImageFromUpstream(
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
maxBytes = CCTV_FRAME_MAX_BODY_BYTES,
|
||||
allowMjpeg = false,
|
||||
} = {},
|
||||
) {
|
||||
if (!url || !/^https?:\/\//i.test(url)) return null;
|
||||
@@ -660,10 +885,7 @@ export async function fetchCctvImageFromUpstream(
|
||||
fetchImpl,
|
||||
);
|
||||
if (!upstream) return null;
|
||||
const contentType = cctvRasterImageType(
|
||||
upstream.headers.get('content-type'),
|
||||
);
|
||||
if (!upstream.ok || !contentType) {
|
||||
if (!upstream.ok) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
@@ -671,9 +893,24 @@ export async function fetchCctvImageFromUpstream(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const body = await readCappedResponseBytes(upstream, maxBytes);
|
||||
const upstreamType = upstream.headers.get('content-type');
|
||||
const contentType = cctvRasterImageType(upstreamType);
|
||||
if (contentType) {
|
||||
const body = await readCappedResponseBytes(upstream, maxBytes);
|
||||
if (!body) return null;
|
||||
return { ok: true, body, contentType };
|
||||
}
|
||||
if (!allowMjpeg || !cctvLiveMediaType('mjpeg', upstreamType)) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const body = await readFirstMjpegFrame(upstream, maxBytes);
|
||||
if (!body) return null;
|
||||
return { ok: true, body, contentType };
|
||||
return { ok: true, body, contentType: 'image/jpeg' };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
@@ -460,6 +460,17 @@ export function isLikelyCataloniaCoordinate(lat, lon) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Iceland bounding box (mainland and nearby islands, with a margin). */
|
||||
export function isLikelyIcelandCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 63.0 &&
|
||||
lat <= 66.7 &&
|
||||
lon >= -25.0 &&
|
||||
lon <= -13.0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode numeric HTML character references, decimal ("í") and hex
|
||||
* ("í") alike, the entity form the Catalonia feed uses for accented
|
||||
@@ -639,6 +650,10 @@ export function normalizeSourceItem(item) {
|
||||
feedType: normalizeFeedType(item.feedType || item.type || ''),
|
||||
url: typeof item.url === 'string' ? item.url : '',
|
||||
snapshotUrl: typeof item.snapshotUrl === 'string' ? item.snapshotUrl : '',
|
||||
frameRefreshMs: toFiniteNumber(item.frameRefreshMs),
|
||||
sourceUrl: typeof item.sourceUrl === 'string' ? item.sourceUrl : '',
|
||||
accuracyM: toFiniteNumber(item.accuracyM),
|
||||
positionSource: String(item.positionSource || ''),
|
||||
license: String(item.license || item.licenseNote || ''),
|
||||
// Per-camera attribution for feeds a partner supplies inside a pack
|
||||
// (DriveBC: TransLink, city cameras). Shown beside the provider.
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
||||
DEFAULT_OTTAWA_MAX_SOURCES,
|
||||
OTTAWA_ANCHORS,
|
||||
OTTAWA_API_USER_ID,
|
||||
OTTAWA_CAMERAS_URL,
|
||||
OTTAWA_FRAME_REFRESH_MS,
|
||||
OTTAWA_IMAGE_ORIGIN,
|
||||
OTTAWA_IMAGE_URL,
|
||||
} from './constants.js';
|
||||
import {
|
||||
fallbackHeadingFromId,
|
||||
pinnedCameraUrl,
|
||||
prioritizeSources,
|
||||
toFiniteNumber,
|
||||
} from './normalize.js';
|
||||
|
||||
let ottawaCertificateMissingLogged = false;
|
||||
|
||||
/** Test hook: let the next missing-certificate skip log again. */
|
||||
export function _resetOttawaCertificateWarningForTest() {
|
||||
ottawaCertificateMissingLogged = false;
|
||||
}
|
||||
|
||||
/** The operator's Traffic Camera Images API certificate, trimmed. */
|
||||
function ottawaCertificate(env = process.env) {
|
||||
return String(env.OTTAWA_CAMERA_CERTIFICATE ?? '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the operator's certificate to a registered keyless Ottawa frame URL.
|
||||
* Only a URL inside the pinned `/opendata/` directory with a numeric camera
|
||||
* number is signed, so the certificate never goes to any other path or host.
|
||||
*
|
||||
* @param {string} url - Canonical frame URL from the catalog.
|
||||
* @param {Record<string,string|undefined>} [env]
|
||||
* @returns {string} The signed URL, or '' when there is no certificate or the
|
||||
* URL is not a registered Ottawa frame.
|
||||
*/
|
||||
export function ottawaAuthorizedFrameUrl(url, env = process.env) {
|
||||
const certificate = ottawaCertificate(env);
|
||||
const pinned = pinnedCameraUrl(url, OTTAWA_IMAGE_ORIGIN);
|
||||
if (!certificate || !pinned) return '';
|
||||
const parsed = new URL(pinned);
|
||||
const cameraNumber = parsed.searchParams.get('c') || '';
|
||||
if (parsed.pathname !== '/opendata/camera' || !/^\d+$/.test(cameraNumber))
|
||||
return '';
|
||||
const signed = new URL(OTTAWA_IMAGE_URL(cameraNumber));
|
||||
signed.searchParams.set('certificate', certificate);
|
||||
signed.searchParams.set('id', OTTAWA_API_USER_ID);
|
||||
return signed.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* City of Ottawa envelope: Kanata to Orléans, Barrhaven to the river, with
|
||||
* slack for the MTO cameras on the 416/417 that the city feed also carries.
|
||||
*/
|
||||
const OTTAWA_BOUNDS = Object.freeze({
|
||||
minLat: 45.1,
|
||||
maxLat: 45.65,
|
||||
minLon: -76.2,
|
||||
maxLon: -75.1,
|
||||
});
|
||||
|
||||
/** Whether a point is inside the city feed's service area. */
|
||||
function isOttawaCoordinate(lat, lon) {
|
||||
return (
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lon) &&
|
||||
lat >= OTTAWA_BOUNDS.minLat &&
|
||||
lat <= OTTAWA_BOUNDS.maxLat &&
|
||||
lon >= OTTAWA_BOUNDS.minLon &&
|
||||
lon <= OTTAWA_BOUNDS.maxLon
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the City of Ottawa's traffic cameras.
|
||||
*
|
||||
* Camera images come from the city's Traffic Camera Images API, which needs a
|
||||
* certificate from the city's registration form (OTTAWA_CAMERA_CERTIFICATE)
|
||||
* and allows one fetch per camera per minute for a certificate and user id.
|
||||
* Without a certificate the pack is skipped, with no request and one log line.
|
||||
* The camera list is the documented `map/service/camera` JSON: a camera
|
||||
* number, English and French names, coordinates and the owner (CITY or MTO).
|
||||
* The feed carries no bearing, no elevation and no status field, so headings
|
||||
* use the same deterministic low-confidence fallback as the other
|
||||
* heading-less packs and the client's ground snap refines the elevation
|
||||
* prior.
|
||||
*
|
||||
* @param {object} [_options]
|
||||
* @param {Record<string,string|undefined>} [env]
|
||||
* @returns {Promise<Array<object>>} Cameras nearest downtown first, or [] on
|
||||
* a missing certificate or any upstream or payload failure.
|
||||
*/
|
||||
export async function loadOttawaSourcesFromOpenData(
|
||||
_options = {},
|
||||
env = process.env,
|
||||
) {
|
||||
if (!ottawaCertificate(env)) {
|
||||
if (!ottawaCertificateMissingLogged) {
|
||||
ottawaCertificateMissingLogged = true;
|
||||
console.warn(
|
||||
'[CCTV] Ottawa cameras skipped: the city camera image API needs a certificate (set OTTAWA_CAMERA_CERTIFICATE)',
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(OTTAWA_CAMERAS_URL, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Ottawa camera download failed:', resp.status);
|
||||
return [];
|
||||
}
|
||||
const payload = await resp.json();
|
||||
const rows = Array.isArray(payload?.cameras) ? payload.cameras : null;
|
||||
if (!rows) return [];
|
||||
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
for (const row of rows) {
|
||||
const number = toFiniteNumber(row?.camera_number);
|
||||
if (!Number.isSafeInteger(number) || number <= 0 || seen.has(number))
|
||||
continue;
|
||||
const lat = toFiniteNumber(row?.latitude);
|
||||
const lon = toFiniteNumber(row?.longitude);
|
||||
if (!isOttawaCoordinate(lat, lon)) continue;
|
||||
seen.add(number);
|
||||
|
||||
const cameraId = `ottawa-${number}`;
|
||||
const imageUrl = OTTAWA_IMAGE_URL(number);
|
||||
// The docs name the field camera_owner; the live feed sends cameraOwner.
|
||||
const owner = String(row?.camera_owner || row?.cameraOwner || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
cameras.push({
|
||||
id: cameraId,
|
||||
name: String(row?.name || '').trim() || `Ottawa camera ${number}`,
|
||||
city: 'Ottawa',
|
||||
cityId: 'ottawa',
|
||||
provider: 'City of Ottawa',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
groundElevationM: 0,
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
frameRefreshMs: OTTAWA_FRAME_REFRESH_MS,
|
||||
sourceKind: 'ottawa-traffic',
|
||||
license: 'City of Ottawa Traffic Camera Images API (registered access)',
|
||||
credit:
|
||||
owner === 'MTO'
|
||||
? 'Ministry of Transportation of Ontario (MTO) camera'
|
||||
: '',
|
||||
});
|
||||
}
|
||||
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_OTTAWA_MAX_SOURCES || DEFAULT_OTTAWA_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(500, Math.floor(maxRaw)))
|
||||
: DEFAULT_OTTAWA_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, OTTAWA_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded Ottawa camera sources: ${cameras.length} published (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Ottawa camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { cameraDisplayCode, fallbackHeadingFromId } from './normalize.js';
|
||||
|
||||
const QUEBEC_CATALOG_PATH = 'public/quebec-cameras.json';
|
||||
export const QUEBEC_CAMERA_SOURCE_COUNT = 1120;
|
||||
|
||||
/**
|
||||
* Whether the operator opted in to the browser-direct Québec 511 video. Off by
|
||||
* default: the camera locations are CC BY 4.0, but whether Québec 511's
|
||||
* service terms allow another site to embed its clips is unconfirmed, so an
|
||||
* unset flag lists the cameras with a link to the 511 viewer page only.
|
||||
*/
|
||||
export function quebecBrowserVideoEnabled(env = process.env) {
|
||||
return String(env.CCTV_QUEBEC_VIDEO_ENABLED || '').trim() === '1';
|
||||
}
|
||||
|
||||
/** Build the direct browser-only MP4 URL for one namespaced MTQ camera id. */
|
||||
export function quebec511VideoUrl(cameraId) {
|
||||
const match = /^qc-511-(\d+)$/.exec(String(cameraId || ''));
|
||||
return match
|
||||
? `https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=${match[1]}&format=mp4`
|
||||
: '';
|
||||
}
|
||||
|
||||
function safeViewerUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value || ''));
|
||||
return url.protocol === 'https:' && !url.username && !url.password
|
||||
? url.href
|
||||
: '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function sourceId(camera) {
|
||||
const id = String(camera?.id || '').trim();
|
||||
if (camera?.kind === 'traffic' && /^\d+$/.test(id)) return `qc-511-${id}`;
|
||||
if (camera?.kind === 'montreal' && /^mtl-\d+(?:-\d+)*$/.test(id))
|
||||
return `qc-mtl-${id.slice(4)}`;
|
||||
if (camera?.kind === 'webcam' && /^web-\d+$/.test(id))
|
||||
return `qc-web-${id.slice(4)}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the checked-in, no-key Québec location catalog and adapt it to the
|
||||
* regular CCTV source schema. MTQ video stays browser-only; other rows link to
|
||||
* their operator page because this dataset has no direct image URL for them.
|
||||
*/
|
||||
export function loadQuebecSources({ sourceRoot = process.cwd() } = {}) {
|
||||
const filename = path.resolve(sourceRoot, QUEBEC_CATALOG_PATH);
|
||||
let cameras;
|
||||
try {
|
||||
const payload = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||
cameras = Array.isArray(payload?.cameras) ? payload.cameras : [];
|
||||
} catch (error) {
|
||||
console.warn('[CCTV] Québec camera catalog unavailable:', error?.message);
|
||||
return [];
|
||||
}
|
||||
|
||||
const sources = [];
|
||||
const seen = new Set();
|
||||
for (const camera of cameras) {
|
||||
const id = sourceId(camera);
|
||||
const lat = Number(camera.lat);
|
||||
const lon = Number(camera.lon);
|
||||
if (
|
||||
!id ||
|
||||
seen.has(id) ||
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lon) ||
|
||||
lat < 44.9 ||
|
||||
lat > 62.6 ||
|
||||
lon < -79.9 ||
|
||||
lon > -57.1
|
||||
)
|
||||
continue;
|
||||
seen.add(id);
|
||||
|
||||
const name = String(camera.nameEn || camera.name || id).trim();
|
||||
const isTraffic = camera.kind === 'traffic';
|
||||
const isMontreal = camera.kind === 'montreal';
|
||||
const sourceKind = isTraffic
|
||||
? 'quebec-511-browser'
|
||||
: isMontreal
|
||||
? 'quebec-montreal-osm'
|
||||
: 'quebec-webcam-link';
|
||||
const provider = isTraffic
|
||||
? 'Québec 511 / MTQ'
|
||||
: isMontreal
|
||||
? 'Ville de Montréal / OpenStreetMap'
|
||||
: String(camera.operator || 'Québec webcam');
|
||||
|
||||
sources.push({
|
||||
id,
|
||||
name: name.slice(0, 180),
|
||||
city: String(camera.region || 'Québec'),
|
||||
cityId: 'quebec',
|
||||
provider,
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: fallbackHeadingFromId(id),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 74,
|
||||
rangeM: 400,
|
||||
mountHeightM: 8,
|
||||
groundElevationM: 0,
|
||||
feedType: 'image',
|
||||
// No frame URL is registered: the server never fetches 511 media. With
|
||||
// CCTV_QUEBEC_VIDEO_ENABLED=1 the browser plays the 511 MP4 in the DOM
|
||||
// overlay after a play click; otherwise the camera links to 511.
|
||||
url: '',
|
||||
snapshotUrl: '',
|
||||
sourceKind,
|
||||
sourceUrl: safeViewerUrl(camera.viewerUrl),
|
||||
accuracyM: Number.isFinite(Number(camera.accuracyM))
|
||||
? Number(camera.accuracyM)
|
||||
: null,
|
||||
positionSource: String(camera.positionSource || ''),
|
||||
license: isTraffic
|
||||
? 'Locations: Données Québec / MTQ, CC BY 4.0. Video: hosted by Québec 511 under its terms of use'
|
||||
: isMontreal
|
||||
? 'OpenStreetMap contributors, ODbL 1.0'
|
||||
: 'Operator terms apply; location provenance shown per camera',
|
||||
credit: isTraffic
|
||||
? 'Locations: MTQ · CC BY 4.0 · Video: Québec 511'
|
||||
: isMontreal
|
||||
? 'OpenStreetMap contributors · ODbL'
|
||||
: provider,
|
||||
code: cameraDisplayCode(name.toUpperCase()),
|
||||
});
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
@@ -134,6 +134,12 @@ import {
|
||||
HOUSTON_MAX_CATALOG_BYTES,
|
||||
HOUSTON_GROUND_ELEVATION_M,
|
||||
HOUSTON_ANCHORS,
|
||||
ICELAND_CAMERAS_URL,
|
||||
ICELAND_IMAGE_ORIGIN,
|
||||
ICELAND_MAX_CATALOG_BYTES,
|
||||
ICELAND_GROUND_ELEVATION_M,
|
||||
DEFAULT_ICELAND_MAX_SOURCES,
|
||||
ICELAND_ANCHORS,
|
||||
} from './constants.js';
|
||||
import {
|
||||
toFiniteNumber,
|
||||
@@ -152,6 +158,7 @@ import {
|
||||
isLikelyCalgaryCoordinate,
|
||||
isLikelyFloridaCoordinate,
|
||||
isLikelyCataloniaCoordinate,
|
||||
isLikelyIcelandCoordinate,
|
||||
decodeNumericEntities,
|
||||
isLikelyNycCoordinate,
|
||||
isLikelyHoustonCoordinate,
|
||||
@@ -2029,6 +2036,174 @@ export async function loadCataloniaSourcesFromOpenData() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compass headings for Icelandic direction words, as they appear after "til"
|
||||
* (genitive: "til vesturs"), "í" (accusative: "í vestur") or "séð" ("seen",
|
||||
* as in "séð austur").
|
||||
*/
|
||||
const ICELAND_DIRECTION_HEADINGS = {
|
||||
norður: 0,
|
||||
norðaustur: 45,
|
||||
austur: 90,
|
||||
suðaustur: 135,
|
||||
suður: 180,
|
||||
suðvestur: 225,
|
||||
vestur: 270,
|
||||
norðvestur: 315,
|
||||
};
|
||||
const ICELAND_DIRECTION_WORD =
|
||||
'(norðaustur|norðvestur|suðaustur|suðvestur|norður|suður|austur|vestur)s?';
|
||||
/** A facing, plus a second one joined by "og" ("til austurs og vesturs"). */
|
||||
const ICELAND_DIRECTION_RE = new RegExp(
|
||||
`(?<![\\p{L}])(?:til|í|séð)\\s+${ICELAND_DIRECTION_WORD}` +
|
||||
`(?:\\s+og\\s+(?:til\\s+|í\\s+)?${ICELAND_DIRECTION_WORD})?(?![\\p{L}])`,
|
||||
'gu',
|
||||
);
|
||||
|
||||
/**
|
||||
* The compass heading an Icelandic view description states, or NaN. Only an
|
||||
* explicit "til <átt>", "í <átt>" or "séð <átt>" counts: a bare place name or
|
||||
* a road direction ("Miklabraut austur") is not a facing. A description naming
|
||||
* two different directions ("til norðurs og til suðurs", "til austurs og
|
||||
* vesturs") is ambiguous and yields NaN, and "niður á veg" (down onto the
|
||||
* road) states none.
|
||||
*
|
||||
* @param {string} text - The feed's `Skyring` field.
|
||||
* @returns {number} Heading in degrees [0..360), or NaN.
|
||||
*/
|
||||
export function icelandHeadingFromDescription(text) {
|
||||
const headings = new Set();
|
||||
for (const match of String(text || '')
|
||||
.toLowerCase()
|
||||
.matchAll(ICELAND_DIRECTION_RE)) {
|
||||
for (const word of [match[1], match[2]]) {
|
||||
if (word) headings.add(ICELAND_DIRECTION_HEADINGS[word]);
|
||||
}
|
||||
}
|
||||
return headings.size === 1 ? headings.values().next().value : NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* One Vegagerðin camera row -> one catalog source, or null. A row is one camera
|
||||
* view; a station (`Maelist_nr`) carries several. The id comes from the frame
|
||||
* file name, which is unique per view in the live feed.
|
||||
*
|
||||
* @param {object} row - Element of the `vefmyndavelar2014_1` array.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function icelandCameraToSource(row) {
|
||||
const url = pinnedCameraUrl(row?.Slod, ICELAND_IMAGE_ORIGIN);
|
||||
if (!url || !/\.jpe?g$/i.test(new URL(url).pathname)) return null;
|
||||
// Numbers only: Number(null) and Number('') are 0.
|
||||
const lat = typeof row.Breidd === 'number' ? row.Breidd : NaN;
|
||||
const lon = typeof row.Lengd === 'number' ? row.Lengd : NaN;
|
||||
if (!isLikelyIcelandCoordinate(lat, lon)) return null;
|
||||
const stem = new URL(url).pathname
|
||||
.slice(new URL(ICELAND_IMAGE_ORIGIN).pathname.length)
|
||||
.replace(/\.jpe?g$/i, '')
|
||||
.toLowerCase();
|
||||
if (!/^[a-z0-9_-]+$/.test(stem)) return null;
|
||||
const cameraId = `is-vegagerdin-${stem}`;
|
||||
const description = String(row.Skyring || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
// Letter lookarounds, not \b: \b is ASCII-only and would join "Þverá
|
||||
// Árnessýslu" while missing "séð séð".
|
||||
.replace(/(?<!\p{L})(\p{L}+) \1(?!\p{L})/giu, '$1')
|
||||
.trim();
|
||||
const station = String(row.Myndavel || '').trim();
|
||||
const heading = icelandHeadingFromDescription(description);
|
||||
const hasHeading = Number.isFinite(heading);
|
||||
return {
|
||||
id: cameraId,
|
||||
name: description || station || `Vegagerðin ${row.Maelist_nr ?? stem}`,
|
||||
city: 'Iceland',
|
||||
cityId: 'iceland',
|
||||
provider: 'Vegagerðin (IRCA)',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: hasHeading ? 'high' : 'low',
|
||||
pitchDeg: hasHeading ? -24 : -18,
|
||||
fovDeg: hasHeading ? 56 : 44,
|
||||
rangeM: hasHeading ? 210 : 145,
|
||||
mountHeightM: hasHeading ? 10 : 8,
|
||||
groundElevationM: ICELAND_GROUND_ELEVATION_M,
|
||||
feedType: 'image',
|
||||
url,
|
||||
snapshotUrl: url,
|
||||
sourceKind: 'iceland-vegagerdin',
|
||||
license:
|
||||
'Icelandic Road and Coastal Administration (IRCA) — road camera images, CC BY 4.0',
|
||||
code: cameraDisplayCode(station.toUpperCase() || description),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Icelandic road cameras (Vegagerðin / IRCA), keyless: one JSON list.
|
||||
* Frames are stills on the IRCA host and the loader registers only links
|
||||
* inside that host's camera directory (ICELAND_IMAGE_ORIGIN). The list decides
|
||||
* ids, coordinates, labels and which frame path is proxied, so it is fetched
|
||||
* over https, redirects are refused and the body read is capped at
|
||||
* ICELAND_MAX_CATALOG_BYTES.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadIcelandSourcesFromOpenData() {
|
||||
try {
|
||||
const resp = await fetch(ICELAND_CAMERAS_URL, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'gods-eye-view-cctv-proxy/1.0',
|
||||
},
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
// A response this loader will not read still owns its transport until the
|
||||
// body is released, so every rejection path cancels before returning.
|
||||
const discard = async () => {
|
||||
try {
|
||||
await resp.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return [];
|
||||
};
|
||||
if (resp.status >= 300 && resp.status < 400) {
|
||||
console.warn(
|
||||
'[CCTV] Iceland catalog redirected; redirects are not followed',
|
||||
);
|
||||
return discard();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Iceland camera download failed:', resp.status);
|
||||
return discard();
|
||||
}
|
||||
const body = await readResponseJsonCapped(resp, ICELAND_MAX_CATALOG_BYTES);
|
||||
const rows = Array.isArray(body) ? body : [];
|
||||
const cameras = rows.map(icelandCameraToSource).filter(Boolean);
|
||||
const unique = Array.from(
|
||||
new Map(cameras.map((camera) => [camera.id, camera])).values(),
|
||||
);
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_ICELAND_MAX_SOURCES || DEFAULT_ICELAND_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(600, Math.floor(maxRaw)))
|
||||
: DEFAULT_ICELAND_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(unique, maxCount, ICELAND_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded Iceland camera sources: ${unique.length} (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Iceland camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one XML child tag's text from an `<image>` block.
|
||||
*
|
||||
|
||||
@@ -5,7 +5,9 @@ export const HLS_LIMITS = Object.freeze({
|
||||
sessions: 2,
|
||||
leasesPerSession: 8,
|
||||
playlistBytes: 256 * 1024,
|
||||
segmentBytes: 4 * 1024 * 1024,
|
||||
// High-resolution cameras can exceed 4 MiB in a single ten-second TS segment.
|
||||
// Retained data still shares the independent 24 MiB session budget below.
|
||||
segmentBytes: 8 * 1024 * 1024,
|
||||
sessionBytes: 24 * 1024 * 1024,
|
||||
segments: 12,
|
||||
pollMs: 2000,
|
||||
|
||||
+158
-11
@@ -1,4 +1,7 @@
|
||||
import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js';
|
||||
import {
|
||||
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
||||
TAIWAN_HIGHWAY_IMAGE_ORIGINS,
|
||||
} from './constants.js';
|
||||
import {
|
||||
cameraDisplayCode,
|
||||
fallbackHeadingFromId,
|
||||
@@ -10,6 +13,8 @@ import { readResponseTextCapped } from '../common/http.js';
|
||||
* standard, published keyless and refreshed daily (~1,800 cameras). */
|
||||
export const TAIWAN_FREEWAY_CCTV_URL =
|
||||
'https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml';
|
||||
export const TAIWAN_HIGHWAY_CCTV_URL =
|
||||
'https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml';
|
||||
/** The whole network is ~1.2 MB. */
|
||||
export const TAIWAN_MAX_CATALOG_BYTES = 6 * 1024 * 1024;
|
||||
const DEFAULT_TAIWAN_MAX_SOURCES = 600;
|
||||
@@ -35,6 +40,9 @@ const STREAM_TO_FRAME = new Map([
|
||||
]);
|
||||
|
||||
const DIRECTIONS = { N: 'N', S: 'S', E: 'E', W: 'W' };
|
||||
const TAIWAN_HIGHWAY_CAMERA_HOSTS = new Set(
|
||||
TAIWAN_HIGHWAY_IMAGE_ORIGINS.map((origin) => new URL(origin).hostname),
|
||||
);
|
||||
|
||||
/** Prioritization anchors, north to south, so a cap keeps the whole island
|
||||
* rather than only greater Taipei. */
|
||||
@@ -211,6 +219,74 @@ export function taiwanCameraToSource(row) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One Highway Bureau row -> a source whose first MJPEG frame is served as a
|
||||
* snapshot by the existing camera-frame proxy.
|
||||
*
|
||||
* @param {object} row - Output of parseTaiwanCctvXml.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function taiwanHighwayCameraToSource(row) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const { lat, lon } = row;
|
||||
if (!isLikelyTaiwanCoordinate(lat, lon)) return null;
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(row.streamUrl ?? ''));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.port ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
!TAIWAN_HIGHWAY_CAMERA_HOSTS.has(url.hostname) ||
|
||||
!/^\/[A-Za-z0-9._+~-]{1,100}$/.test(url.pathname) ||
|
||||
url.pathname.includes('..')
|
||||
)
|
||||
return null;
|
||||
|
||||
const rawId = String(row.cctvId ?? '').trim();
|
||||
if (!/^[A-Za-z0-9-]{1,80}$/.test(rawId)) return null;
|
||||
const cameraId = 'tw-thb-' + rawId.toLowerCase();
|
||||
const road = String(row.roadName ?? '').trim();
|
||||
const direction = DIRECTIONS[String(row.direction ?? '').trim()] || '';
|
||||
const mile = String(row.mile ?? '').trim();
|
||||
const section = [row.sectionStart, row.sectionEnd]
|
||||
.map((part) => String(part ?? '').trim())
|
||||
.filter(Boolean)
|
||||
.join('→');
|
||||
const name =
|
||||
(road || mile || section
|
||||
? [road, direction, mile, section].filter(Boolean).join(' ')
|
||||
: '') || rawId;
|
||||
|
||||
return {
|
||||
id: cameraId,
|
||||
name: name.slice(0, 160),
|
||||
city: 'Taiwan / 臺灣',
|
||||
cityId: 'taiwan',
|
||||
provider: 'Highway Bureau, MOTC',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
feedType: 'mjpeg',
|
||||
url: url.href,
|
||||
snapshotUrl: url.href,
|
||||
sourceKind: 'taiwan-highway-motc',
|
||||
license: 'Highway Bureau, MOTC open data',
|
||||
code: cameraDisplayCode((road + ' ' + mile).trim().toUpperCase() || rawId),
|
||||
};
|
||||
}
|
||||
let lastGood = null;
|
||||
let lastGoodAt = 0;
|
||||
let retryAfter = 0;
|
||||
@@ -237,6 +313,49 @@ function taiwanSourceCap() {
|
||||
: DEFAULT_TAIWAN_MAX_SOURCES;
|
||||
}
|
||||
|
||||
/** Fetch the public provincial-road CCTV table as a no-key fallback. */
|
||||
async function loadTaiwanHighwaySources() {
|
||||
const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS);
|
||||
const response = await fetch(TAIWAN_HIGHWAY_CCTV_URL, {
|
||||
headers: { Accept: 'application/xml,text/xml' },
|
||||
redirect: 'manual',
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
throw new Error(
|
||||
response.status >= 300 && response.status < 400
|
||||
? 'Highway Bureau catalog redirected; redirects are not followed'
|
||||
: 'Highway Bureau catalog HTTP ' + response.status,
|
||||
);
|
||||
}
|
||||
const xml = await readResponseTextCapped(
|
||||
response,
|
||||
TAIWAN_MAX_CATALOG_BYTES,
|
||||
signal,
|
||||
);
|
||||
const cameras = new Map();
|
||||
for (const row of parseTaiwanCctvXml(xml)) {
|
||||
const camera = taiwanHighwayCameraToSource(row);
|
||||
if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera);
|
||||
}
|
||||
if (!cameras.size)
|
||||
throw new Error('no usable Highway Bureau cameras in the table');
|
||||
const sources = prioritizeSources(
|
||||
[...cameras.values()],
|
||||
taiwanSourceCap(),
|
||||
ANCHORS,
|
||||
);
|
||||
console.log(
|
||||
'[CCTV] Loaded Taiwan Highway Bureau fallback: ' +
|
||||
cameras.size +
|
||||
' (using ' +
|
||||
sources.length +
|
||||
')',
|
||||
);
|
||||
return sources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Taiwan Freeway Bureau cameras from the keyless MOTC 2.0 XML table.
|
||||
* One bounded request per shared catalog refresh. A failed download serves the
|
||||
@@ -262,7 +381,7 @@ export async function loadTaiwanSources() {
|
||||
throw new Error(
|
||||
response.status >= 300 && response.status < 400
|
||||
? 'catalog redirected; redirects are not followed'
|
||||
: `HTTP ${response.status}`,
|
||||
: 'HTTP ' + response.status,
|
||||
);
|
||||
}
|
||||
const xml = await readResponseTextCapped(
|
||||
@@ -282,7 +401,11 @@ export async function loadTaiwanSources() {
|
||||
ANCHORS,
|
||||
);
|
||||
console.log(
|
||||
`[CCTV] Loaded Taiwan camera sources: ${cameras.size} (using ${sources.length})`,
|
||||
'[CCTV] Loaded Taiwan camera sources: ' +
|
||||
cameras.size +
|
||||
' (using ' +
|
||||
sources.length +
|
||||
')',
|
||||
);
|
||||
lastGood = sources;
|
||||
lastGoodAt = Date.now();
|
||||
@@ -291,13 +414,37 @@ export async function loadTaiwanSources() {
|
||||
} catch (error) {
|
||||
retryAfter = Date.now() + TAIWAN_RETRY_AFTER_FAILURE_MS;
|
||||
const fallback = fallbackSources();
|
||||
console.warn(
|
||||
`[CCTV] Taiwan camera download failed (${error?.message || error}); ` +
|
||||
(fallback.length
|
||||
? `serving the last good ${fallback.length} cameras`
|
||||
: 'no Taiwan cameras') +
|
||||
', next attempt in 1 h',
|
||||
);
|
||||
return fallback;
|
||||
if (fallback.length) {
|
||||
console.warn(
|
||||
'[CCTV] Taiwan Freeway camera download failed (' +
|
||||
(error?.message || error) +
|
||||
'); serving the last good ' +
|
||||
fallback.length +
|
||||
' cameras, next attempt in 1 h',
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
const sources = await loadTaiwanHighwaySources();
|
||||
lastGood = sources;
|
||||
lastGoodAt = Date.now();
|
||||
console.warn(
|
||||
'[CCTV] Taiwan Freeway camera download failed (' +
|
||||
(error?.message || error) +
|
||||
'); using ' +
|
||||
sources.length +
|
||||
' Highway Bureau cameras as fallback, next attempt in 1 h',
|
||||
);
|
||||
return sources;
|
||||
} catch (fallbackError) {
|
||||
console.warn(
|
||||
'[CCTV] Taiwan Freeway camera download failed (' +
|
||||
(error?.message || error) +
|
||||
'); Highway Bureau fallback failed (' +
|
||||
(fallbackError?.message || fallbackError) +
|
||||
'); no Taiwan cameras, next attempt in 1 h',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
DEFAULT_WORLD_CCTV_SOURCE_FILE,
|
||||
MLIT_RIVER_IMAGE_ORIGIN,
|
||||
TRIPCHECK_IMAGE_ORIGIN,
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
} from './constants.js';
|
||||
import { pinnedCameraUrl } from './normalize.js';
|
||||
|
||||
const SOURCE_POLICIES = Object.freeze({
|
||||
'tripcheck-odot': {
|
||||
idPrefix: 'world-or-',
|
||||
origin: TRIPCHECK_IMAGE_ORIGIN,
|
||||
bounds: [41, 47, -125, -116],
|
||||
filename: /^\/roadcams\/cams\/[^/]+\.jpe?g$/i,
|
||||
frameRefreshMs: 5 * 60 * 1000,
|
||||
},
|
||||
'mlit-river': {
|
||||
idPrefix: 'world-jp-river-',
|
||||
origin: MLIT_RIVER_IMAGE_ORIGIN,
|
||||
bounds: [24, 46, 122, 154],
|
||||
filename: /^\/cam\/now\/\d+\.jpg$/i,
|
||||
// The provider asks clients to avoid frequent repeated image collection.
|
||||
frameRefreshMs: 10 * 60 * 1000,
|
||||
},
|
||||
});
|
||||
|
||||
// MLIT currently serves this fixed JPEG on cameras whose images are paused
|
||||
// for maintenance (verified against two Yamagata cameras on 2026-11-02).
|
||||
const MLIT_ADJUSTMENT_IMAGE_SHA256 =
|
||||
'dbd2c3790eb75edf9ca44162f7979f2a3799090e23f2c5142f63211a035cb191';
|
||||
|
||||
export function isMlitRiverAdjustmentImage(image) {
|
||||
return (
|
||||
image?.ok === true &&
|
||||
Buffer.isBuffer(image.body) &&
|
||||
createHash('sha256').update(image.body).digest('hex') ===
|
||||
MLIT_ADJUSTMENT_IMAGE_SHA256
|
||||
);
|
||||
}
|
||||
|
||||
function safeFrameUrl(value, policy) {
|
||||
const normalized = pinnedCameraUrl(value, policy.origin);
|
||||
if (!normalized) return '';
|
||||
const url = new URL(normalized);
|
||||
return !url.search && !url.hash && policy.filename.test(url.pathname)
|
||||
? url.href
|
||||
: '';
|
||||
}
|
||||
|
||||
function inBounds(lat, lon, [minLat, maxLat, minLon, maxLon]) {
|
||||
return (
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lon) &&
|
||||
lat >= minLat &&
|
||||
lat <= maxLat &&
|
||||
lon >= minLon &&
|
||||
lon <= maxLon
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load only the new image-backed networks from the add-on export. Existing
|
||||
* Canadian networks, Québec entries, JMA placeholder images, and NZTA's
|
||||
* scraped Journey Planner rows are deliberately excluded. Vegagerðin (Iceland)
|
||||
* rows are excluded too: the Iceland pack loads the full live list of those
|
||||
* cameras, so a World copy would draw each one twice.
|
||||
*/
|
||||
export function loadWorldCameraSources({ sourceRoot = process.cwd() } = {}) {
|
||||
const filename = path.resolve(sourceRoot, DEFAULT_WORLD_CCTV_SOURCE_FILE);
|
||||
let rows;
|
||||
try {
|
||||
const payload = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||
rows = Array.isArray(payload) ? payload : [];
|
||||
} catch (error) {
|
||||
console.warn('[CCTV] World camera catalog unavailable:', error?.message);
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const sources = [];
|
||||
for (const row of rows) {
|
||||
const sourceKind = String(row?.sourceKind || '');
|
||||
const policy = SOURCE_POLICIES[sourceKind];
|
||||
if (!policy) continue;
|
||||
|
||||
const id = String(row.id || '').trim();
|
||||
const lat = Number(row.lat);
|
||||
const lon = Number(row.lon);
|
||||
const url = safeFrameUrl(row.url || row.snapshotUrl, policy);
|
||||
const snapshotUrl = safeFrameUrl(row.snapshotUrl || row.url, policy);
|
||||
if (
|
||||
!id.startsWith(policy.idPrefix) ||
|
||||
seen.has(id) ||
|
||||
!inBounds(lat, lon, policy.bounds) ||
|
||||
!url ||
|
||||
!snapshotUrl ||
|
||||
url !== snapshotUrl
|
||||
)
|
||||
continue;
|
||||
seen.add(id);
|
||||
sources.push({
|
||||
id,
|
||||
name: String(row.name || id)
|
||||
.trim()
|
||||
.slice(0, 180),
|
||||
city: String(row.city || row.country || 'World'),
|
||||
cityId: String(row.cityId || ''),
|
||||
provider: String(row.provider || 'World traffic camera'),
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: Number.isFinite(Number(row.headingDeg))
|
||||
? Number(row.headingDeg)
|
||||
: undefined,
|
||||
headingConfidence: String(row.headingConfidence || 'low'),
|
||||
feedType: 'image',
|
||||
sourceKind,
|
||||
url,
|
||||
snapshotUrl,
|
||||
frameRefreshMs: policy.frameRefreshMs,
|
||||
license: String(row.license || ''),
|
||||
credit: String(row.credit || row.provider || ''),
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[CCTV] Loaded World camera image sources: ${sources.length}/${WORLD_CCTV_SOURCE_COUNT}`,
|
||||
);
|
||||
return sources;
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -64,6 +64,30 @@ function validMessages(messages) {
|
||||
return !pending.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool schema without the `pattern`s Chat Completions refuses: it answers
|
||||
* 400 for a whole request whose tool uses Unicode property classes (`\p{L}`),
|
||||
* as `resolve_area`'s area ids do. Every argument is still checked against
|
||||
* the action's own contract before the tool runs, so the model loses a hint,
|
||||
* not a rule. Realtime voice accepts these patterns and keeps them.
|
||||
*/
|
||||
export function chatSchema(schema) {
|
||||
if (Array.isArray(schema)) return schema.map(chatSchema);
|
||||
if (!object(schema)) return schema;
|
||||
return Object.fromEntries(
|
||||
Object.entries(schema)
|
||||
.filter(
|
||||
([key, value]) =>
|
||||
!(
|
||||
key === 'pattern' &&
|
||||
typeof value === 'string' &&
|
||||
/\\[pP]\{/.test(value)
|
||||
),
|
||||
)
|
||||
.map(([key, value]) => [key, chatSchema(value)]),
|
||||
);
|
||||
}
|
||||
|
||||
export function createOpenAiChatHandler({
|
||||
annotationGuidance,
|
||||
tools = GEV_REALTIME_TOOLS,
|
||||
@@ -126,7 +150,12 @@ export function createOpenAiChatHandler({
|
||||
tools: offeredTools(tools, { overpass }).map(
|
||||
({ name, description, parameters }) => ({
|
||||
type: 'function',
|
||||
function: { name, description, parameters, strict: false },
|
||||
function: {
|
||||
name,
|
||||
description,
|
||||
parameters: chatSchema(parameters),
|
||||
strict: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -135,7 +135,7 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
control_cockpit: {
|
||||
description:
|
||||
'Explicit Cockpit requests only: enter from a selected/tracked aircraft (establishes Contacts itself; no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.',
|
||||
'Explicit Cockpit requests only: enter from a selected/tracked aircraft (needs only its own Flights/Military layer on, not Contacts; opens Contacts itself, no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.',
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
@@ -523,7 +523,7 @@ export const ACTION_DESCRIPTIONS = {
|
||||
properties: {
|
||||
direction: {
|
||||
description:
|
||||
'Required for pan/tilt/rotate; orbit/rotate_globe default right.',
|
||||
'Pan/rotate/tilt steps; orbit/rotate_globe default right.',
|
||||
$position: 2,
|
||||
},
|
||||
mode: {
|
||||
@@ -531,6 +531,10 @@ export const ACTION_DESCRIPTIONS = {
|
||||
'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.',
|
||||
$position: 2,
|
||||
},
|
||||
pitchDeg: {
|
||||
description: 'Absolute; -45 default, -89 top-down.',
|
||||
$position: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,7 +2,20 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createApplication } from './application.js';
|
||||
import { installTrackpadPinchZoom } from './viewer.js';
|
||||
import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
installFreeLookHeightLimit,
|
||||
installGlobeInViewRecovery,
|
||||
installTrackpadPinchZoom,
|
||||
} from './viewer.js';
|
||||
import {
|
||||
FREE_LOOK_MAX_HEIGHT_METERS,
|
||||
globeFrameShare,
|
||||
globeOutOfFrame,
|
||||
lookAngleWithinFrame,
|
||||
lookStepLosesGlobe,
|
||||
} from '../globeInView.js';
|
||||
|
||||
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
|
||||
function fixture(overrides = {}) {
|
||||
@@ -343,3 +356,652 @@ test('trackpad pinch falls back to the original event when relay construction fa
|
||||
assert.equal(event.propagationStopped, false);
|
||||
assert.equal(f.relayed.length, 0);
|
||||
});
|
||||
|
||||
function freeLookFixture({ enableLook = true, height = 0 } = {}) {
|
||||
const listeners = new Set();
|
||||
const controller = { enableLook, minimumTrackBallHeight: 7_500_000 };
|
||||
const camera = { positionCartographic: { height } };
|
||||
const viewer = {
|
||||
camera,
|
||||
scene: {
|
||||
screenSpaceCameraController: controller,
|
||||
preUpdate: {
|
||||
addEventListener(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const frame = (nextHeight) => {
|
||||
camera.positionCartographic = { height: nextHeight };
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
return { viewer, controller, listeners, frame };
|
||||
}
|
||||
|
||||
test('free look follows camera height and is restored on dispose', () => {
|
||||
const f = freeLookFixture({ height: 500 });
|
||||
const dispose = installFreeLookHeightLimit(f.viewer);
|
||||
assert.equal(f.controller.enableLook, true);
|
||||
|
||||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS + 1);
|
||||
assert.equal(f.controller.enableLook, false);
|
||||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS);
|
||||
assert.equal(f.controller.enableLook, true);
|
||||
f.frame(Number.NaN);
|
||||
assert.equal(f.controller.enableLook, true);
|
||||
|
||||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
|
||||
dispose();
|
||||
dispose();
|
||||
assert.equal(f.controller.enableLook, true);
|
||||
assert.equal(f.listeners.size, 0);
|
||||
});
|
||||
|
||||
test('drags that start on the stars orbit above the free-look height', () => {
|
||||
// Below Cesium's trackball height such a drag turns the camera in place.
|
||||
const f = freeLookFixture({ height: 500 });
|
||||
const dispose = installFreeLookHeightLimit(f.viewer);
|
||||
assert.equal(
|
||||
f.controller.minimumTrackBallHeight,
|
||||
FREE_LOOK_MAX_HEIGHT_METERS,
|
||||
);
|
||||
dispose();
|
||||
assert.equal(f.controller.minimumTrackBallHeight, 7_500_000);
|
||||
|
||||
const lower = freeLookFixture({ height: 500 });
|
||||
lower.controller.minimumTrackBallHeight = 500_000;
|
||||
const disposeLower = installFreeLookHeightLimit(lower.viewer);
|
||||
assert.equal(
|
||||
lower.controller.minimumTrackBallHeight,
|
||||
500_000,
|
||||
'never raised',
|
||||
);
|
||||
disposeLower();
|
||||
assert.equal(lower.controller.minimumTrackBallHeight, 500_000);
|
||||
|
||||
const later = freeLookFixture({ height: 500 });
|
||||
const disposeLater = installFreeLookHeightLimit(later.viewer);
|
||||
later.controller.minimumTrackBallHeight = 2_000_000;
|
||||
disposeLater();
|
||||
assert.equal(
|
||||
later.controller.minimumTrackBallHeight,
|
||||
2_000_000,
|
||||
'dispose keeps a value set after install',
|
||||
);
|
||||
});
|
||||
|
||||
test('free look keeps a setting other code makes after it is installed', () => {
|
||||
const f = freeLookFixture({ height: 500 });
|
||||
const dispose = installFreeLookHeightLimit(f.viewer);
|
||||
f.controller.enableLook = false;
|
||||
f.frame(10);
|
||||
assert.equal(f.controller.enableLook, false, 'turned off stays off');
|
||||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
|
||||
f.frame(10);
|
||||
assert.equal(f.controller.enableLook, false, 'and stays off after orbit');
|
||||
f.controller.enableLook = true;
|
||||
f.frame(10);
|
||||
assert.equal(f.controller.enableLook, true, 'turned back on');
|
||||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
|
||||
assert.equal(f.controller.enableLook, false, 'still limited from orbit');
|
||||
f.frame(10);
|
||||
f.controller.enableLook = false;
|
||||
dispose();
|
||||
assert.equal(f.controller.enableLook, false, 'dispose keeps a later setting');
|
||||
});
|
||||
|
||||
test('free look stays off when the viewer had it disabled', () => {
|
||||
const f = freeLookFixture({ enableLook: false, height: 500 });
|
||||
installFreeLookHeightLimit(f.viewer);
|
||||
f.frame(10);
|
||||
assert.equal(f.controller.enableLook, false);
|
||||
});
|
||||
|
||||
// A real Cesium camera on the smallest scene its constructor reads, so the
|
||||
// frustum test runs on Cesium's own view math rather than a stub.
|
||||
function realCamera({ width = 1600, height = 900, mode } = {}) {
|
||||
const scene = {
|
||||
canvas: { clientWidth: width, clientHeight: height },
|
||||
drawingBufferWidth: width,
|
||||
drawingBufferHeight: height,
|
||||
mapProjection: new Cesium.GeographicProjection(),
|
||||
mode: mode ?? Cesium.SceneMode.SCENE3D,
|
||||
pixelRatio: 1,
|
||||
ellipsoid: Cesium.Ellipsoid.WGS84,
|
||||
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
|
||||
tweens: new Cesium.TweenCollection(),
|
||||
renders: 0,
|
||||
requestRender() {
|
||||
this.renders += 1;
|
||||
},
|
||||
};
|
||||
const camera = new Cesium.Camera(scene);
|
||||
camera.frustum.aspectRatio = width / height;
|
||||
return { camera, scene, viewer: { camera, scene } };
|
||||
}
|
||||
|
||||
function aim(
|
||||
camera,
|
||||
{ lon = -94.69, lat = 18.87, alt, heading = 0, pitch, roll = 0 },
|
||||
) {
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(lon, lat, alt),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(heading),
|
||||
pitch: Cesium.Math.toRadians(pitch),
|
||||
roll: Cesium.Math.toRadians(roll),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const reportedStuckView = {
|
||||
alt: 17_996_811,
|
||||
heading: 180,
|
||||
pitch: -42,
|
||||
roll: 180,
|
||||
};
|
||||
// Oblique poses that still show part of the planet: Director recipe shots
|
||||
// (Global Flights Radar, Omniscience Pullback) and a voice tilt from orbit.
|
||||
const partialGlobeViews = [
|
||||
{ lon: -30, lat: 20, alt: 19_000_000, pitch: -65 },
|
||||
{ lon: 2, lat: 46, alt: 8_000_000, pitch: -52 },
|
||||
{ lon: 110, lat: 20, alt: 9_500_000, pitch: -66 },
|
||||
{ lon: 0, lat: 0, alt: 2_000_000, pitch: -30 },
|
||||
];
|
||||
|
||||
// Stars and a few pixels of limb, no surface: where a frame limit measured
|
||||
// against a sphere of the planet's largest radius used to stop a voice tilt.
|
||||
const limbOnlyView = { alt: 18_000_000, pitch: -56.75 };
|
||||
|
||||
/**
|
||||
* An oracle independent of the code under test: the share of an 80×45 grid of
|
||||
* Cesium pick rays across the canvas that hit the ellipsoid surface.
|
||||
*/
|
||||
function surfacePixelShare(camera, width, height, { keyhole = false } = {}) {
|
||||
const radius = (KEYHOLE_OUTER_RADIUS * height) / 2;
|
||||
let hits = 0;
|
||||
let counted = 0;
|
||||
for (let row = 0; row < 45; row += 1) {
|
||||
for (let column = 0; column < 80; column += 1) {
|
||||
const x = ((column + 0.5) * width) / 80;
|
||||
const y = ((row + 0.5) * height) / 45;
|
||||
if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius)
|
||||
continue;
|
||||
counted += 1;
|
||||
const ray = camera.getPickRay(new Cesium.Cartesian2(x, y));
|
||||
if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84))
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
return hits / counted;
|
||||
}
|
||||
|
||||
test('the globe is out of frame only when no surface is on screen', () => {
|
||||
const cases = [
|
||||
[reportedStuckView, 1225, 923, true],
|
||||
[limbOnlyView, 1600, 900, true],
|
||||
[{ alt: 17_996_811, pitch: -90 }, 1225, 923, false],
|
||||
...partialGlobeViews.map((view) => [view, 1600, 900, false]),
|
||||
];
|
||||
for (const [view, width, height, out] of cases) {
|
||||
const { camera } = realCamera({ width, height });
|
||||
aim(camera, view);
|
||||
const label = JSON.stringify(view);
|
||||
assert.equal(globeOutOfFrame(camera), out, label);
|
||||
assert.equal(surfacePixelShare(camera, width, height) === 0, out, label);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
globeFrameShare(camera) - surfacePixelShare(camera, width, height),
|
||||
) < 0.03,
|
||||
`${label}: the frame share matches the pick-ray oracle`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a move that ends with the globe out of frame turns back to nadir', () => {
|
||||
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
const dispose = installGlobeInViewRecovery(viewer);
|
||||
aim(camera, reportedStuckView);
|
||||
const position = Cesium.Cartesian3.clone(camera.positionWC);
|
||||
const heading = camera.heading;
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.heading, heading, 1e-6));
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.equalsEpsilon(camera.positionWC, position, 0, 1e-3),
|
||||
);
|
||||
assert.equal(globeOutOfFrame(camera), false);
|
||||
assert.equal(scene.renders, 1);
|
||||
|
||||
// Looking up near the ground, then zoomed out past the limit.
|
||||
aim(camera, { alt: 3_834_000, pitch: 10 });
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||||
|
||||
// Only the limb on a 16:9 screen still counts as lost.
|
||||
const wide = realCamera({ width: 1600, height: 900 });
|
||||
const disposeWide = installGlobeInViewRecovery(wide.viewer);
|
||||
aim(wide.camera, limbOnlyView);
|
||||
wide.camera.moveEnd.raiseEvent();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(wide.camera.pitch, -Math.PI / 2, 1e-6));
|
||||
disposeWide();
|
||||
|
||||
dispose();
|
||||
dispose();
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'disposed recovery is inert');
|
||||
});
|
||||
|
||||
// True when a moveEnd leaves the camera's view direction as it was.
|
||||
function moveEndKeepsView(camera) {
|
||||
const direction = Cesium.Cartesian3.clone(camera.directionWC);
|
||||
camera.moveEnd.raiseEvent();
|
||||
return Cesium.Cartesian3.equalsEpsilon(camera.directionWC, direction, 1e-9);
|
||||
}
|
||||
|
||||
test('globe recovery leaves partial, low, locked and 2D views alone', () => {
|
||||
for (const view of partialGlobeViews) {
|
||||
const { camera, viewer } = realCamera();
|
||||
installGlobeInViewRecovery(viewer);
|
||||
aim(camera, view);
|
||||
assert.ok(moveEndKeepsView(camera), JSON.stringify(view));
|
||||
}
|
||||
|
||||
const low = realCamera();
|
||||
installGlobeInViewRecovery(low.viewer);
|
||||
aim(low.camera, { alt: 900_000, pitch: 85 });
|
||||
assert.equal(globeOutOfFrame(low.camera), true);
|
||||
assert.ok(moveEndKeepsView(low.camera), 'below the limit');
|
||||
|
||||
// Locked on a point in space, between it and the planet, facing away.
|
||||
const locked = realCamera();
|
||||
installGlobeInViewRecovery(locked.viewer);
|
||||
locked.camera.lookAtTransform(
|
||||
Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(30_000_000, 0, 0)),
|
||||
new Cesium.Cartesian3(-1_000_000, 0, 0),
|
||||
);
|
||||
assert.equal(globeOutOfFrame(locked.camera), true);
|
||||
assert.ok(moveEndKeepsView(locked.camera), 'locked camera');
|
||||
|
||||
// Tracking an entity that has no position yet keeps an identity transform.
|
||||
const tracked = realCamera();
|
||||
tracked.viewer.trackedEntity = { id: 'pending-fix' };
|
||||
installGlobeInViewRecovery(tracked.viewer);
|
||||
aim(tracked.camera, reportedStuckView);
|
||||
assert.ok(moveEndKeepsView(tracked.camera), 'tracked camera');
|
||||
|
||||
const flat = realCamera({ mode: Cesium.SceneMode.COLUMBUS_VIEW });
|
||||
installGlobeInViewRecovery(flat.viewer);
|
||||
flat.scene.mode = Cesium.SceneMode.SCENE3D;
|
||||
aim(flat.camera, reportedStuckView);
|
||||
flat.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW;
|
||||
assert.ok(moveEndKeepsView(flat.camera), 'Columbus view');
|
||||
});
|
||||
|
||||
test('the look limit from orbit tolerates grid noise and never reaches zero', () => {
|
||||
const radians = Cesium.Math.toRadians;
|
||||
// About 2% of a 16:9 frame on the planet, below the 10% limit.
|
||||
const sliver = { alt: 18_000_000, pitch: -60 };
|
||||
const { camera } = realCamera();
|
||||
aim(camera, sliver);
|
||||
const start = surfacePixelShare(camera, 1600, 900);
|
||||
assert.ok(start > 0 && start < 0.1, `starts below the limit: ${start}`);
|
||||
|
||||
// Sideways across the sliver: the share wobbles by a cell, the planet stays.
|
||||
assert.equal(lookStepLosesGlobe(camera, 'lookLeft', radians(0.5)), false);
|
||||
const startShare = globeFrameShare(camera);
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const step = lookAngleWithinFrame(camera, 'lookLeft', radians(0.25), {
|
||||
startShare,
|
||||
});
|
||||
assert.equal(step, radians(0.25), `sideways step ${i + 1}`);
|
||||
camera.lookLeft(step);
|
||||
}
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
|
||||
// Toward the planet is always allowed.
|
||||
aim(camera, sliver);
|
||||
assert.equal(
|
||||
lookAngleWithinFrame(camera, 'lookDown', radians(10)),
|
||||
radians(10),
|
||||
);
|
||||
|
||||
// Away from it stops short of losing it.
|
||||
aim(camera, sliver);
|
||||
const away = lookAngleWithinFrame(camera, 'lookUp', radians(30));
|
||||
assert.ok(away < radians(30));
|
||||
camera.lookUp(away);
|
||||
assert.ok(globeFrameShare(camera) > 0);
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
});
|
||||
|
||||
test('the look limit leaves a view with no surface to the move-end recovery', () => {
|
||||
const { camera } = realCamera({ width: 1225, height: 923 });
|
||||
aim(camera, reportedStuckView);
|
||||
assert.equal(
|
||||
lookAngleWithinFrame(camera, 'lookUp', Cesium.Math.toRadians(5)),
|
||||
Cesium.Math.toRadians(5),
|
||||
);
|
||||
});
|
||||
|
||||
// Surface only in the corners of a 16:9 frame, all of it outside the scope's
|
||||
// keyhole: the full frame shows some planet, the scope shows none.
|
||||
const cornerOnlyView = { alt: 18_000_000, pitch: -52, roll: 60 };
|
||||
|
||||
test('with a scope keyhole, surface hidden in the masked corners counts as none', () => {
|
||||
const { camera } = realCamera();
|
||||
aim(camera, cornerOnlyView);
|
||||
const keyhole = { keyholeRadius: KEYHOLE_OUTER_RADIUS };
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
assert.equal(surfacePixelShare(camera, 1600, 900, { keyhole: true }), 0);
|
||||
assert.equal(globeOutOfFrame(camera), false);
|
||||
assert.equal(globeOutOfFrame(camera, keyhole), true);
|
||||
aim(camera, { alt: 18_000_000, pitch: -90 });
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
globeFrameShare(camera, keyhole) -
|
||||
surfacePixelShare(camera, 1600, 900, { keyhole: true }),
|
||||
) < 0.03,
|
||||
'the keyhole share matches the pick-ray oracle',
|
||||
);
|
||||
});
|
||||
|
||||
test('the recovery follows the scope: corner-only views are turned back only behind it', () => {
|
||||
for (const [scopeOn, expectRecovery] of [
|
||||
[true, true],
|
||||
[false, false],
|
||||
]) {
|
||||
const { camera, viewer } = realCamera();
|
||||
installGlobeInViewRecovery(viewer, {
|
||||
keyholeRadius: () => (scopeOn ? KEYHOLE_OUTER_RADIUS : 0),
|
||||
});
|
||||
aim(camera, cornerOnlyView);
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.equal(
|
||||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
|
||||
expectRecovery,
|
||||
`scope ${scopeOn ? 'on' : 'off'}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the recovery leaves a camera flight and an authored scene alone', () => {
|
||||
let playing = true;
|
||||
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
scene.camera = camera;
|
||||
scene.screenSpaceCameraController = { enableInputs: true };
|
||||
installGlobeInViewRecovery(viewer, { paused: () => playing });
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'a playing scene is left alone');
|
||||
playing = false;
|
||||
|
||||
// A flight that pauses looking away is left alone until it ends.
|
||||
camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(10, 20, 18_000_000),
|
||||
duration: 5,
|
||||
});
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'a flight is left alone');
|
||||
camera.cancelFlight();
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||||
});
|
||||
|
||||
test('a check a scene or flight defers runs once it ends', () => {
|
||||
for (const viaMoveEnd of [false, true]) {
|
||||
let playing = true;
|
||||
const timers = fakeTimers();
|
||||
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
installGlobeInViewRecovery(viewer, {
|
||||
paused: () => playing,
|
||||
...timers,
|
||||
});
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveStart.raiseEvent();
|
||||
// A hold on a stars-only shot: quiet, with or without Cesium's moveEnd.
|
||||
for (let i = 0; i < 4; i += 1) timers.tick();
|
||||
if (viaMoveEnd) camera.moveEnd.raiseEvent();
|
||||
// Longer than the settle check's five-minute bound.
|
||||
for (let i = 0; i < 1500; i += 1) timers.tick();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'left alone while it plays');
|
||||
assert.equal(timers.pending(), 1, 'still watching');
|
||||
playing = false;
|
||||
timers.tick();
|
||||
assert.ok(
|
||||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
|
||||
`recovered once the scene ends (${viaMoveEnd ? 'after moveEnd' : 'no moveEnd'})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Holding a drag still for half a second on a view that already showed no
|
||||
// surface looked like a settled camera, and the view was turned down under
|
||||
// the user's hand.
|
||||
test('the recovery waits while a pointer is held on the globe', () => {
|
||||
const timers = fakeTimers();
|
||||
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
const document = new EventTarget();
|
||||
scene.canvas = Object.assign(new EventTarget(), {
|
||||
clientWidth: 1225,
|
||||
clientHeight: 923,
|
||||
ownerDocument: document,
|
||||
});
|
||||
const dispose = installGlobeInViewRecovery(viewer, timers);
|
||||
aim(camera, reportedStuckView);
|
||||
scene.canvas.dispatchEvent(new Event('pointerdown'));
|
||||
camera.moveStart.raiseEvent();
|
||||
for (let i = 0; i < 8; i += 1) timers.tick();
|
||||
camera.moveEnd.raiseEvent();
|
||||
for (let i = 0; i < 8; i += 1) timers.tick();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'left alone while held');
|
||||
document.dispatchEvent(new Event('pointerup'));
|
||||
for (let i = 0; i < 3; i += 1) timers.tick();
|
||||
assert.ok(
|
||||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
|
||||
'recovered once the pointer is released',
|
||||
);
|
||||
dispose();
|
||||
aim(camera, reportedStuckView);
|
||||
scene.canvas.dispatchEvent(new Event('pointerdown'));
|
||||
document.dispatchEvent(new Event('pointerup'));
|
||||
assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners');
|
||||
});
|
||||
|
||||
/** A held-pointer recovery over a canvas in a document in a window. */
|
||||
function heldPointerRecovery() {
|
||||
const timers = fakeTimers();
|
||||
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
const window = new EventTarget();
|
||||
const document = Object.assign(new EventTarget(), {
|
||||
defaultView: window,
|
||||
visibilityState: 'visible',
|
||||
});
|
||||
scene.canvas = Object.assign(new EventTarget(), {
|
||||
clientWidth: 1225,
|
||||
clientHeight: 923,
|
||||
ownerDocument: document,
|
||||
});
|
||||
const dispose = installGlobeInViewRecovery(viewer, timers);
|
||||
const pointer = (target, type, pointerId, init = {}) =>
|
||||
target.dispatchEvent(
|
||||
Object.assign(new Event(type), { pointerId, ...init }),
|
||||
);
|
||||
/** Press, move and hold still: the check defers while a pointer is held. */
|
||||
const holdStill = () => {
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveStart.raiseEvent();
|
||||
for (let i = 0; i < 8; i += 1) timers.tick();
|
||||
camera.moveEnd.raiseEvent();
|
||||
for (let i = 0; i < 8; i += 1) timers.tick();
|
||||
};
|
||||
const settle = () => {
|
||||
for (let i = 0; i < 3; i += 1) timers.tick();
|
||||
};
|
||||
const recovered = () =>
|
||||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6);
|
||||
return {
|
||||
camera,
|
||||
document,
|
||||
window,
|
||||
dispose,
|
||||
pointer,
|
||||
holdStill,
|
||||
settle,
|
||||
recovered,
|
||||
canvas: scene.canvas,
|
||||
};
|
||||
}
|
||||
|
||||
// Releasing one of two fingers cleared the held flag, and the recovery
|
||||
// turned the view down under the finger still on the glass.
|
||||
test('the recovery waits until every held pointer is released', () => {
|
||||
const h = heldPointerRecovery();
|
||||
h.pointer(h.canvas, 'pointerdown', 1);
|
||||
h.pointer(h.canvas, 'pointerdown', 2);
|
||||
h.holdStill();
|
||||
h.pointer(h.document, 'pointerup', 1);
|
||||
h.settle();
|
||||
assert.equal(h.recovered(), false, 'left alone under the second finger');
|
||||
assert.equal(globeOutOfFrame(h.camera), true);
|
||||
h.pointer(h.document, 'pointercancel', 2);
|
||||
h.settle();
|
||||
assert.equal(h.recovered(), true, 'recovered once both are released');
|
||||
h.dispose();
|
||||
});
|
||||
|
||||
// A release the page never sees (the pointer let go outside the window)
|
||||
// must not hold the recovery off for good.
|
||||
for (const [label, lose] of [
|
||||
['the window loses focus', (h) => h.window.dispatchEvent(new Event('blur'))],
|
||||
[
|
||||
'the page is hidden',
|
||||
(h) => {
|
||||
h.document.visibilityState = 'hidden';
|
||||
h.document.dispatchEvent(new Event('visibilitychange'));
|
||||
},
|
||||
],
|
||||
[
|
||||
'the canvas loses the pointer capture',
|
||||
(h) => h.pointer(h.canvas, 'lostpointercapture', 1),
|
||||
],
|
||||
[
|
||||
'the pointer moves with no button down',
|
||||
(h) => h.pointer(h.document, 'pointermove', 1, { buttons: 0 }),
|
||||
],
|
||||
]) {
|
||||
test(`a held pointer whose release is lost stops deferring when ${label}`, () => {
|
||||
const h = heldPointerRecovery();
|
||||
h.pointer(h.canvas, 'pointerdown', 1);
|
||||
h.holdStill();
|
||||
h.pointer(h.document, 'pointermove', 1, { buttons: 1 });
|
||||
h.settle();
|
||||
assert.equal(h.recovered(), false, 'left alone while held');
|
||||
lose(h);
|
||||
h.settle();
|
||||
assert.equal(h.recovered(), true, 'recovered once the hold is let go');
|
||||
h.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
function fakeTimers() {
|
||||
let next = 1;
|
||||
const pending = new Map();
|
||||
return {
|
||||
setTimeout: (callback) => {
|
||||
const id = next++;
|
||||
pending.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
clearTimeout: (id) => pending.delete(id),
|
||||
pending: () => pending.size,
|
||||
/** Run the earliest pending callback, as 250 ms passing would. */
|
||||
tick() {
|
||||
const [id, callback] = pending.entries().next().value ?? [];
|
||||
if (id === undefined) return false;
|
||||
pending.delete(id);
|
||||
callback();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a stuck view is recovered once it has held still, without waiting for moveEnd', () => {
|
||||
// At this pose the camera can drift by a rounding error every frame, so
|
||||
// Cesium never sees it stop and never raises moveEnd for it.
|
||||
const timers = fakeTimers();
|
||||
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
installGlobeInViewRecovery(viewer, timers);
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveStart.raiseEvent();
|
||||
timers.tick();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'one quiet sample is not enough');
|
||||
// A rounding-error drift between samples still counts as at rest.
|
||||
camera.lookUp(1e-15);
|
||||
timers.tick();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||||
// Under that drift Cesium raises no new moveStart, so a later move that
|
||||
// ends looking away is caught by the same check.
|
||||
aim(camera, reportedStuckView);
|
||||
timers.tick();
|
||||
timers.tick();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'not yet at rest');
|
||||
timers.tick();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.equal(timers.pending(), 0, 'moveEnd ends the check');
|
||||
});
|
||||
|
||||
test('the settle check follows a long move and gives up five minutes after the last one', () => {
|
||||
// A drag of half a minute that stops at a stuck pose is still recovered.
|
||||
const long = fakeTimers();
|
||||
const dragged = realCamera({ width: 1225, height: 923 });
|
||||
installGlobeInViewRecovery(dragged.viewer, long);
|
||||
aim(dragged.camera, reportedStuckView);
|
||||
dragged.camera.moveStart.raiseEvent();
|
||||
for (let i = 0; i < 120; i += 1) {
|
||||
dragged.camera.moveLeft(0.01);
|
||||
long.tick();
|
||||
}
|
||||
assert.equal(globeOutOfFrame(dragged.camera), true, 'still moving');
|
||||
while (long.tick());
|
||||
assert.ok(
|
||||
Cesium.Math.equalsEpsilon(dragged.camera.pitch, -Math.PI / 2, 1e-6),
|
||||
'recovered once it stopped',
|
||||
);
|
||||
|
||||
// A camera that keeps moving is followed, and left alone, for as long as it
|
||||
// moves; once it rests the check gives up five minutes later.
|
||||
const timers = fakeTimers();
|
||||
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
installGlobeInViewRecovery(viewer, timers);
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveStart.raiseEvent();
|
||||
for (let i = 0; i < 2000; i += 1) {
|
||||
camera.moveLeft(0.01);
|
||||
timers.tick();
|
||||
}
|
||||
assert.equal(timers.pending(), 1, 'still following after eight minutes');
|
||||
assert.equal(globeOutOfFrame(camera), true, 'a moving camera is left alone');
|
||||
let idleSamples = 0;
|
||||
while (timers.tick()) idleSamples += 1;
|
||||
assert.ok(
|
||||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
|
||||
'recovered once it stopped',
|
||||
);
|
||||
assert.ok(
|
||||
idleSamples >= 1200 && idleSamples <= 1205,
|
||||
`gave up ${idleSamples} samples after it stopped`,
|
||||
);
|
||||
|
||||
const disposed = fakeTimers();
|
||||
const other = realCamera({ width: 1225, height: 923 });
|
||||
const dispose = installGlobeInViewRecovery(other.viewer, disposed);
|
||||
aim(other.camera, reportedStuckView);
|
||||
other.camera.moveStart.raiseEvent();
|
||||
dispose();
|
||||
assert.equal(disposed.pending(), 0, 'dispose cancels the pending check');
|
||||
});
|
||||
@@ -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([], [])),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { resolveCatalogSources } from './sourceComposition.js';
|
||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||
import { createApplicationCyclones } from './layers/cyclones.js';
|
||||
import { createWindLayer } from '../layers/wind/index.js';
|
||||
import { createLayerCatalog } from './catalog.js';
|
||||
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
||||
@@ -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,
|
||||
@@ -177,7 +182,7 @@ export function createApplicationCatalog({
|
||||
id: 'weather-lightning',
|
||||
clock: weatherClock,
|
||||
}),
|
||||
createCyclonesLayer({ feed: sources.cyclones }),
|
||||
createApplicationCyclones({ feed: sources.cyclones }),
|
||||
...createOsmOverlayLayers(),
|
||||
...createInfrastructureLayers(localGeoJsonServices),
|
||||
createApplicationCables({ source: sources.cables }),
|
||||
|
||||
@@ -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,10 @@
|
||||
import { createCyclonesLayer } from '../../layers/cyclones/index.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
|
||||
/** Wire cyclone selection to the application shared context. */
|
||||
export function createApplicationCyclones(options) {
|
||||
return createCyclonesLayer({
|
||||
context: typeof window !== 'undefined' ? context : null,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createApplicationOperations } from './operations.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createApplicationViewer,
|
||||
installFreeLookHeightLimit,
|
||||
installTrackpadPinchZoom,
|
||||
} from '../app/viewer.js';
|
||||
import { registerDataCredits } from '../data/dataCredits.js';
|
||||
@@ -58,6 +59,7 @@ export async function createApplicationScene({
|
||||
if (!viewer.isDestroyed()) viewer.destroy();
|
||||
});
|
||||
defer(installTrackpadPinchZoom(viewer));
|
||||
defer(installFreeLookHeightLimit(viewer));
|
||||
registerDataCredits(viewer, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
||||
|
||||
@@ -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 },
|
||||
|
||||
+15
-1
@@ -8,7 +8,13 @@ import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
|
||||
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
||||
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
|
||||
import { installViews, isEmbeddedInline } from './embed.js';
|
||||
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
|
||||
import {
|
||||
installScopeMask,
|
||||
destroyScopeMask,
|
||||
isScopeMaskEnabled,
|
||||
} from '../scopeMask.js';
|
||||
import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
|
||||
import { installGlobeInViewRecovery } from './viewer.js';
|
||||
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
|
||||
import {
|
||||
installRenderGovernor,
|
||||
@@ -44,6 +50,14 @@ export function createApplicationTools({
|
||||
.get('bhote-koshi-2026')
|
||||
?.module.attachSceneController(sceneDirector);
|
||||
defer(() => sceneDirector.destroy());
|
||||
defer(
|
||||
installGlobeInViewRecovery(viewer, {
|
||||
// The scope mask hides everything outside its keyhole.
|
||||
keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0),
|
||||
// An authored scene frames its own shots, holds included.
|
||||
paused: () => sceneDirector.running,
|
||||
}),
|
||||
);
|
||||
onSceneDirector?.(sceneDirector);
|
||||
const annotations = initAnnotations({
|
||||
viewer,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
|
||||
import { StarrySky } from '../sky/starrySky.js';
|
||||
import {
|
||||
FREE_LOOK_MAX_HEIGHT_METERS,
|
||||
globeOutOfFrame,
|
||||
isFreeGlobeCamera,
|
||||
} from '../globeInView.js';
|
||||
|
||||
const PINCH_ZOOM_MULTIPLIER = 8;
|
||||
const MAX_PINCH_PIXEL_DELTA = 120;
|
||||
@@ -103,6 +108,231 @@ export function installTrackpadPinchZoom(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow Cesium's free look only below `maxHeight` and return its disposer.
|
||||
* Orbit, tilt and zoom stay available at every height. A value other code
|
||||
* sets later is kept as the one to allow below that height, and restored on
|
||||
* dispose; a `false` written while the camera is above `maxHeight` cannot be
|
||||
* told apart from this limit's own write, so dispose restores the earlier
|
||||
* value then. `enableLook` gates the Shift+drag binding only: below its trackball
|
||||
* height Cesium also turns the camera in place for a drag that starts on the
|
||||
* stars. That height is lowered to `maxHeight` too, so above it such a drag
|
||||
* orbits the globe, as it does above Cesium's own 7,500 km.
|
||||
*/
|
||||
export function installFreeLookHeightLimit(
|
||||
viewer,
|
||||
maxHeight = FREE_LOOK_MAX_HEIGHT_METERS,
|
||||
) {
|
||||
const scene = viewer?.scene;
|
||||
const controller = scene?.screenSpaceCameraController;
|
||||
const camera = viewer?.camera;
|
||||
if (!controller || !camera || !scene?.preUpdate)
|
||||
throw new TypeError('A complete Cesium viewer is required');
|
||||
|
||||
const originalTrackBallHeight = controller.minimumTrackBallHeight;
|
||||
const trackBallHeight = Math.min(originalTrackBallHeight, maxHeight);
|
||||
controller.minimumTrackBallHeight = trackBallHeight;
|
||||
let wanted = controller.enableLook;
|
||||
let written;
|
||||
const sync = () => {
|
||||
if (written !== undefined && controller.enableLook !== written)
|
||||
wanted = controller.enableLook;
|
||||
const height = camera.positionCartographic?.height;
|
||||
written = wanted && !(Number.isFinite(height) && height > maxHeight);
|
||||
controller.enableLook = written;
|
||||
};
|
||||
sync();
|
||||
const removeListener = scene.preUpdate.addEventListener(sync);
|
||||
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
removeListener();
|
||||
if (controller.enableLook === written) controller.enableLook = wanted;
|
||||
if (controller.minimumTrackBallHeight === trackBallHeight)
|
||||
controller.minimumTrackBallHeight = originalTrackBallHeight;
|
||||
};
|
||||
}
|
||||
|
||||
// How often a moving camera is sampled to see whether it has come to rest, how
|
||||
// far a pose may drift between samples and still count as at rest, and how
|
||||
// long after the camera last moved the check gives up if Cesium never raises
|
||||
// moveEnd.
|
||||
const SETTLE_SAMPLE_MS = 250;
|
||||
const SETTLE_POSITION_TOLERANCE_M = 1e-3;
|
||||
const SETTLE_DIRECTION_TOLERANCE = 1e-9;
|
||||
const SETTLE_GIVE_UP_MS = 5 * 60_000;
|
||||
|
||||
/**
|
||||
* Turn the camera back to the planet when a move ends above `minHeight` with
|
||||
* no surface on screen (`globeOutOfFrame`: limb glow and slivers up to about
|
||||
* one sample cell thick can count as none), and return the disposer. Views with
|
||||
* more surface, such as an oblique orbital shot, are left as they are. This
|
||||
* catches a saved or shared view that looked away, or any other move that ends
|
||||
* looking away from the planet.
|
||||
* Heading and position are kept. Tracked cameras, locked ones (non-identity
|
||||
* transform), a camera flight in progress, a pointer held down on the canvas
|
||||
* (a drag held still), the 2D and Columbus modes, and any time `paused`
|
||||
* returns true (an authored scene playing) are left alone; a check they defer
|
||||
* runs once they end, however long they last.
|
||||
* `keyholeRadius`
|
||||
* returns the radius of the visible circle when a scope mask hides the rest of
|
||||
* the frame (see `globeFrameShare`), or 0; it is read at every check.
|
||||
*
|
||||
* Cesium's `moveEnd` alone is not enough: Cesium compares the camera with the
|
||||
* last frame's to the last rounding error, and at some of these poses the
|
||||
* camera drifts by a rounding error every frame, so Cesium never sees it stop.
|
||||
* So each `moveStart` also starts a check of its own that samples the pose
|
||||
* every 250 ms, with a tolerance for that drift, and once the pose has held
|
||||
* for half a second after moving runs the same recovery. It keeps watching
|
||||
* until Cesium raises `moveEnd`, since under that drift Cesium raises no new
|
||||
* `moveStart` for a later move, and gives up five minutes after the camera
|
||||
* last moved.
|
||||
* `setTimeout` and `clearTimeout` can be injected for tests.
|
||||
*/
|
||||
export function installGlobeInViewRecovery(
|
||||
viewer,
|
||||
{
|
||||
minHeight = FREE_LOOK_MAX_HEIGHT_METERS,
|
||||
keyholeRadius = () => 0,
|
||||
paused = () => false,
|
||||
setTimeout: schedule = globalThis.setTimeout,
|
||||
clearTimeout: unschedule = globalThis.clearTimeout,
|
||||
} = {},
|
||||
) {
|
||||
const scene = viewer?.scene;
|
||||
const camera = viewer?.camera;
|
||||
if (!scene || !camera?.moveEnd || !camera?.moveStart)
|
||||
throw new TypeError('A complete Cesium viewer is required');
|
||||
|
||||
// A pointer held on the globe is a gesture in progress, even when it holds
|
||||
// still for half a second: the check waits until every pointer pressed on
|
||||
// it (each finger of a pinch) is released. A release the page never sees,
|
||||
// outside the window, is taken as given when the window loses focus, the
|
||||
// page is hidden, the canvas loses the pointer's capture, or the pointer
|
||||
// moves with no button down.
|
||||
const canvas = scene.canvas;
|
||||
const releaseTarget = canvas?.ownerDocument ?? canvas;
|
||||
const releaseWindow = canvas?.ownerDocument?.defaultView;
|
||||
const heldPointers = new Set();
|
||||
const onPointerDown = (event) => heldPointers.add(event?.pointerId);
|
||||
const onPointerUp = (event) => heldPointers.delete(event?.pointerId);
|
||||
const onPointerMove = (event) => {
|
||||
if (event?.buttons === 0) heldPointers.delete(event.pointerId);
|
||||
};
|
||||
const releaseAll = () => heldPointers.clear();
|
||||
const onVisibilityChange = () => {
|
||||
if (canvas.ownerDocument.visibilityState === 'hidden') releaseAll();
|
||||
};
|
||||
const listeners = [
|
||||
[canvas, 'pointerdown', onPointerDown],
|
||||
[canvas, 'lostpointercapture', onPointerUp],
|
||||
[releaseTarget, 'pointerup', onPointerUp],
|
||||
[releaseTarget, 'pointercancel', onPointerUp],
|
||||
[releaseTarget, 'pointermove', onPointerMove],
|
||||
[canvas?.ownerDocument, 'visibilitychange', onVisibilityChange],
|
||||
[releaseWindow, 'blur', releaseAll],
|
||||
];
|
||||
for (const [target, type, listener] of listeners)
|
||||
target?.addEventListener?.(type, listener);
|
||||
|
||||
// Returns false while a flight, a paused scene or a held pointer defers the
|
||||
// check, so the caller can try again once it ends.
|
||||
const recover = () => {
|
||||
if (!isFreeGlobeCamera(viewer)) return true;
|
||||
// Cesium has no public running-flight flag.
|
||||
if (camera._currentFlight || paused() || heldPointers.size > 0)
|
||||
return false;
|
||||
const height = camera.positionCartographic?.height;
|
||||
if (
|
||||
!(height > minHeight) ||
|
||||
!globeOutOfFrame(camera, { keyholeRadius: keyholeRadius() })
|
||||
)
|
||||
return true;
|
||||
camera.setView({
|
||||
orientation: {
|
||||
heading: camera.heading,
|
||||
pitch: -Cesium.Math.PI_OVER_TWO,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
scene.requestRender?.();
|
||||
return true;
|
||||
};
|
||||
|
||||
const lastPosition = new Cesium.Cartesian3();
|
||||
const lastDirection = new Cesium.Cartesian3();
|
||||
let settleTimer = null;
|
||||
let settleElapsed = 0;
|
||||
let restingSamples = 0;
|
||||
let movedSinceCheck = false;
|
||||
const stopSettling = () => {
|
||||
if (settleTimer !== null) unschedule(settleTimer);
|
||||
settleTimer = null;
|
||||
};
|
||||
const sample = () => {
|
||||
settleTimer = null;
|
||||
settleElapsed += SETTLE_SAMPLE_MS;
|
||||
const atRest =
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
camera.positionWC,
|
||||
lastPosition,
|
||||
0,
|
||||
SETTLE_POSITION_TOLERANCE_M,
|
||||
) &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
camera.directionWC,
|
||||
lastDirection,
|
||||
0,
|
||||
SETTLE_DIRECTION_TOLERANCE,
|
||||
);
|
||||
restingSamples = atRest ? restingSamples + 1 : 0;
|
||||
if (!atRest) {
|
||||
movedSinceCheck = true;
|
||||
settleElapsed = 0;
|
||||
}
|
||||
// Two quiet samples in a row after a move: at rest for half a second, as
|
||||
// for moveEnd.
|
||||
if (restingSamples >= 2 && movedSinceCheck) {
|
||||
if (recover()) movedSinceCheck = false;
|
||||
// A flight or a playing scene bounds its own deferral.
|
||||
else settleElapsed = 0;
|
||||
}
|
||||
if (settleElapsed >= SETTLE_GIVE_UP_MS) return;
|
||||
Cesium.Cartesian3.clone(camera.positionWC, lastPosition);
|
||||
Cesium.Cartesian3.clone(camera.directionWC, lastDirection);
|
||||
settleTimer = schedule(sample, SETTLE_SAMPLE_MS);
|
||||
};
|
||||
const startSettling = () => {
|
||||
stopSettling();
|
||||
settleElapsed = 0;
|
||||
restingSamples = 0;
|
||||
movedSinceCheck = true;
|
||||
Cesium.Cartesian3.clone(camera.positionWC, lastPosition);
|
||||
Cesium.Cartesian3.clone(camera.directionWC, lastDirection);
|
||||
settleTimer = schedule(sample, SETTLE_SAMPLE_MS);
|
||||
};
|
||||
const removeMoveEnd = camera.moveEnd.addEventListener(() => {
|
||||
if (recover()) stopSettling();
|
||||
// Deferred: keep sampling so the check runs once the flight or scene ends.
|
||||
else if (settleTimer === null) startSettling();
|
||||
});
|
||||
const removeMoveStart = camera.moveStart.addEventListener(startSettling);
|
||||
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
stopSettling();
|
||||
removeMoveEnd();
|
||||
removeMoveStart();
|
||||
for (const [target, type, listener] of listeners)
|
||||
target?.removeEventListener?.(type, listener);
|
||||
heldPointers.clear();
|
||||
};
|
||||
}
|
||||
|
||||
/** Cesium.Viewer options of the standard globe viewer. */
|
||||
export function applicationViewerOptions(creditContainer) {
|
||||
return {
|
||||
|
||||
@@ -13,8 +13,16 @@ import {
|
||||
moveCamera,
|
||||
} from './cameraVerbs.js';
|
||||
import { getRenderGovernorDiagnostics } from './renderGovernor.js';
|
||||
import {
|
||||
MIN_GLOBE_FRAME_SHARE,
|
||||
globeFrameShare,
|
||||
globeOutOfFrame,
|
||||
} from './globeInView.js';
|
||||
import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js';
|
||||
import { setScopeMaskEnabled } from './scopeMask.js';
|
||||
|
||||
const radians = Cesium.Math.toRadians;
|
||||
const degrees = Cesium.Math.toDegrees;
|
||||
const clone = (value) => Cesium.Cartesian3.clone(value);
|
||||
const longitude = (camera) =>
|
||||
Cesium.Cartographic.fromCartesian(camera.positionWC).longitude;
|
||||
@@ -1347,3 +1355,664 @@ test('close-up ultrawide refusal does not offer an unfit reset or move the camer
|
||||
samePose(h.camera, before);
|
||||
assert.equal(hasRenderHold(), false);
|
||||
});
|
||||
|
||||
/**
|
||||
* An oracle independent of the code under test: the share of an 80×45 grid of
|
||||
* Cesium pick rays across the canvas that hit the ellipsoid surface. With
|
||||
* `keyhole`, only the pixels inside the scope's visible circle count.
|
||||
*/
|
||||
function surfacePixelShare(camera, width, height, { keyhole = false } = {}) {
|
||||
const radius = (KEYHOLE_OUTER_RADIUS * height) / 2;
|
||||
let hits = 0;
|
||||
let counted = 0;
|
||||
for (let row = 0; row < 45; row += 1) {
|
||||
for (let column = 0; column < 80; column += 1) {
|
||||
const x = ((column + 0.5) * width) / 80;
|
||||
const y = ((row + 0.5) * height) / 45;
|
||||
if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius)
|
||||
continue;
|
||||
counted += 1;
|
||||
const ray = camera.getPickRay(new Cesium.Cartesian2(x, y));
|
||||
if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84))
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
return hits / counted;
|
||||
}
|
||||
|
||||
/** Run a test with the scope mask on or off, restoring the default after. */
|
||||
function withScope(t, enabled) {
|
||||
setScopeMaskEnabled(enabled);
|
||||
t.after(() => setScopeMaskEnabled(true));
|
||||
}
|
||||
|
||||
// The frame limit is measured on a coarser grid than the oracle.
|
||||
const SHARE_SLACK = 0.02;
|
||||
// Far-out poses, height in metres and pitch in degrees, where a few tilts up
|
||||
// are accepted before the limit refuses the next one.
|
||||
const FAR_OUT_POSES = [
|
||||
[35_884_000, -86.26],
|
||||
[35_884_000, -89],
|
||||
[30_000_000, -86],
|
||||
[25_000_000, -80],
|
||||
[40_000_000, -88],
|
||||
];
|
||||
|
||||
test('voice tilt from orbit stops with the planet still on screen and then says so', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// Slightly off nadir: from straight down a tilt is clamped before it moves.
|
||||
const { camera, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const results = [];
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'up',
|
||||
mode: 'once',
|
||||
});
|
||||
results.push(result);
|
||||
if (result.ok) finishOnce();
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1600, 900) >=
|
||||
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
|
||||
`after tilt ${i + 1}`,
|
||||
);
|
||||
}
|
||||
// From 18,000 km a 16:9 frame keeps a tenth of its area on the planet down
|
||||
// to about -67°: the first tilt stops there, and later ones are refused.
|
||||
assert.deepEqual(
|
||||
results.map((result) => result.ok),
|
||||
[true, false, false, false],
|
||||
);
|
||||
assert.match(results[1].error, /globe out of view/);
|
||||
assert.ok(camera.pitch > radians(-68.5) && camera.pitch < radians(-66));
|
||||
});
|
||||
|
||||
// The frame limit judges a tilt by the way its pitch moves, not by the
|
||||
// direction word: a tilt down or to a steeper angle turns toward the planet.
|
||||
for (const args of [{ direction: 'down' }, { pitchDeg: -80 }]) {
|
||||
test(`a tilt ${JSON.stringify(args)} from the frame limit is judged as the look down it is`, (t) => {
|
||||
withScope(t, false);
|
||||
const { camera, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
|
||||
finishOnce();
|
||||
const atLimit = camera.pitch;
|
||||
const result = moveCamera({ motion: 'tilt', ...args });
|
||||
assert.equal(result.ok, true, result.error);
|
||||
finishOnce();
|
||||
assert.ok(camera.pitch < atLimit - radians(5), 'the tilt turned down');
|
||||
});
|
||||
}
|
||||
|
||||
for (const args of [{ pitchDeg: -10 }, {}]) {
|
||||
test(`a tilt ${JSON.stringify(args)} up from the frame limit is refused, not reported as done`, (t) => {
|
||||
withScope(t, false);
|
||||
const { camera, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
|
||||
finishOnce();
|
||||
const atLimit = camera.pitch;
|
||||
// A bare tilt means -45°: up from the -67° limit.
|
||||
const result = moveCamera({ motion: 'tilt', ...args });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /globe out of view/);
|
||||
assert.equal(camera.pitch, atLimit);
|
||||
});
|
||||
}
|
||||
|
||||
// A tilt to an angle, or a bare tilt (-45°), from orbit turns in place like
|
||||
// a nudge and must keep the planet in frame the whole way, or the globe
|
||||
// recovery would snap the view back down.
|
||||
for (const [label, args, options] of [
|
||||
['a bare tilt', { motion: 'tilt' }, { pitch: -90 }],
|
||||
['a tilt to -10°', { motion: 'tilt', pitchDeg: -10 }, { pitch: -90 }],
|
||||
[
|
||||
'a continuous tilt up',
|
||||
{ motion: 'tilt', direction: 'up', mode: 'continuous' },
|
||||
{ pitch: -90 },
|
||||
],
|
||||
[
|
||||
'a tilt to -5° from 2,000 km',
|
||||
{ motion: 'tilt', pitchDeg: -5 },
|
||||
{ pitch: -90, height: 2_000_000 },
|
||||
],
|
||||
]) {
|
||||
test(`${label} from top-down never turns the planet out of frame`, (t) => {
|
||||
withScope(t, false);
|
||||
const { camera, tick } = harness(t, {
|
||||
...options,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const startPitch = camera.pitch;
|
||||
const result = moveCamera(args);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
tick();
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1600, 900) >=
|
||||
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
|
||||
`frame ${i}: the planet left the frame`,
|
||||
);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
|
||||
assert.equal(globeOutOfFrame(camera), false, 'no snap-back');
|
||||
if (result.ok) assert.ok(camera.pitch > startPitch, 'the tilt moved');
|
||||
});
|
||||
}
|
||||
|
||||
// The frame limit stops a tilt to an angle short of it: the reply names the
|
||||
// angle the camera reaches, not the one asked for (a tilt to -10° from
|
||||
// top-down reported -10° and ended at -66.5°).
|
||||
for (const [label, args, options] of [
|
||||
['a bare tilt', { motion: 'tilt' }, { pitch: -90 }],
|
||||
['a tilt to -10°', { motion: 'tilt', pitchDeg: -10 }, { pitch: -90 }],
|
||||
[
|
||||
'a tilt to -10° from -80°',
|
||||
{ motion: 'tilt', pitchDeg: -10 },
|
||||
{ pitch: -80 },
|
||||
],
|
||||
[
|
||||
'a tilt to -5° from 2,000 km',
|
||||
{ motion: 'tilt', pitchDeg: -5 },
|
||||
{ pitch: -90, height: 2_000_000 },
|
||||
],
|
||||
// A rolled camera's look turns its pitch by less than the look angle:
|
||||
// reported -25°, ended at -27.3°.
|
||||
[
|
||||
'a tilt to -5° from 2,000 km rolled 30°',
|
||||
{ motion: 'tilt', pitchDeg: -5 },
|
||||
{ pitch: -70, roll: 30, height: 2_000_000 },
|
||||
],
|
||||
]) {
|
||||
test(`${label} from orbit reports the angle it reaches`, (t) => {
|
||||
withScope(t, false);
|
||||
const { camera, tick } = harness(t, {
|
||||
...options,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const result = moveCamera(args);
|
||||
assert.equal(result.ok, true, result.error);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
|
||||
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
|
||||
const reached = degrees(camera.pitch);
|
||||
assert.ok(
|
||||
Math.abs(reached - result.pitchDeg) < 1,
|
||||
`reported ${result.pitchDeg}°, reached ${reached}°`,
|
||||
);
|
||||
const asked = args.pitchDeg ?? -45;
|
||||
if (Math.abs(reached - asked) >= 1)
|
||||
assert.match(result.note ?? '', /globe out of view/);
|
||||
else assert.equal(result.note, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
// A continuous tilt promises no angle: it runs until stopped or the frame
|
||||
// limit ends it.
|
||||
test('a continuous tilt up from orbit claims no angle it may not reach', (t) => {
|
||||
withScope(t, false);
|
||||
const { tick } = harness(t, {
|
||||
pitch: -90,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'up',
|
||||
mode: 'continuous',
|
||||
});
|
||||
assert.equal(result.ok, true, result.error);
|
||||
assert.equal('pitchDeg' in result, false);
|
||||
assert.equal(result.mode, 'continuous');
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
|
||||
assert.equal(getActiveCameraMotion(), null, 'the frame limit ends it');
|
||||
});
|
||||
|
||||
test('voice turn in place from orbit stops with the planet still on screen', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
const { camera, tick } = harness(t, { pitch: -75 });
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok,
|
||||
true,
|
||||
);
|
||||
const heading = camera.heading;
|
||||
for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) {
|
||||
tick();
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1280, 800) >=
|
||||
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
|
||||
`tick ${i + 1}`,
|
||||
);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
|
||||
assert.notEqual(camera.heading, heading, 'the turn moved before stopping');
|
||||
});
|
||||
|
||||
test('a long voice turn below the limit keeps the share it started with', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// About 2% of a 16:9 frame on the planet: the limit is that share, less two
|
||||
// grid cells of sampling noise, for the whole motion rather than per step.
|
||||
const { camera, tick } = harness(t, {
|
||||
pitch: -60,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const start = globeFrameShare(camera);
|
||||
assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok,
|
||||
true,
|
||||
);
|
||||
for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) {
|
||||
tick();
|
||||
assert.ok(
|
||||
globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9,
|
||||
`tick ${i + 1}`,
|
||||
);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
});
|
||||
|
||||
test('repeated voice turns from one spot cannot each take a little more', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// About 2% of a 16:9 frame on the planet. Every turn from this position is
|
||||
// measured against the same baseline, so asking again stops being allowed.
|
||||
const { camera, finishOnce, tick } = harness(t, {
|
||||
pitch: -60,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const start = globeFrameShare(camera);
|
||||
const floor = start - 2 / (32 * 18) - 1e-9;
|
||||
const results = [];
|
||||
for (const mode of ['once', 'once', 'continuous', 'once', 'continuous']) {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const result = moveCamera({ motion: 'rotate', direction: 'left', mode });
|
||||
results.push(result.ok);
|
||||
if (!result.ok) continue;
|
||||
if (mode === 'once') finishOnce();
|
||||
else for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick();
|
||||
assert.ok(globeFrameShare(camera) >= floor, `${mode} turn ${i + 1}`);
|
||||
}
|
||||
}
|
||||
assert.ok(results.includes(false), 'a turn at the limit is refused');
|
||||
assert.equal(results.at(-1), false);
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
});
|
||||
|
||||
test('a voice turn after something else turned the camera starts over', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// The same spot, first framed straight down, then turned to about 2% of the
|
||||
// frame by something other than voice: a turn left is measured from there.
|
||||
const { camera, finishOnce } = harness(t, {
|
||||
pitch: -90,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok,
|
||||
true,
|
||||
);
|
||||
finishOnce();
|
||||
camera.setView({
|
||||
orientation: { heading: 0, pitch: radians(-60), roll: 0 },
|
||||
});
|
||||
const start = globeFrameShare(camera);
|
||||
assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok,
|
||||
true,
|
||||
);
|
||||
finishOnce();
|
||||
assert.ok(globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9);
|
||||
});
|
||||
|
||||
test('nudging the camera between voice turns cannot take the planet below the 1% floor', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// A wheel notch or an orbit drag between requests starts each turn afresh;
|
||||
// the 1% floor still holds, and below it a turn may only keep what it has.
|
||||
for (const [nudge, pitch] of [
|
||||
[(camera) => camera.moveBackward(2), -60],
|
||||
[(camera) => camera.rotateRight(radians(0.2)), -60],
|
||||
[(camera) => camera.moveBackward(2), -58],
|
||||
]) {
|
||||
const { camera, tick } = harness(t, {
|
||||
pitch,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const start = globeFrameShare(camera);
|
||||
const floor = Math.min(0.01, start) - 1e-9;
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
nudge(camera);
|
||||
if (
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' })
|
||||
.ok
|
||||
)
|
||||
for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick();
|
||||
assert.ok(
|
||||
globeFrameShare(camera) >= floor,
|
||||
`start ${start}, turn ${i + 1}`,
|
||||
);
|
||||
}
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('after the frame changes size, a voice turn toward the planet is allowed', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// A tilt stops at its limit; then the window widens at the same pose, so the
|
||||
// frame shows less than the carried limit. Toward the planet must still go.
|
||||
const { camera, canvas, viewer, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok,
|
||||
true,
|
||||
);
|
||||
finishOnce();
|
||||
canvas.clientWidth = canvas.width = viewer.scene.drawingBufferWidth = 3200;
|
||||
camera.frustum.aspectRatio = 3200 / 900;
|
||||
const narrowed = globeFrameShare(camera);
|
||||
assert.ok(narrowed < MIN_GLOBE_FRAME_SHARE, `frame now shows ${narrowed}`);
|
||||
|
||||
const down = moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' });
|
||||
assert.equal(down.ok, true, down.error);
|
||||
finishOnce();
|
||||
assert.ok(globeFrameShare(camera) > narrowed);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' }).ok,
|
||||
true,
|
||||
);
|
||||
finishOnce();
|
||||
const up = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
|
||||
if (up.ok) finishOnce();
|
||||
assert.ok(surfacePixelShare(camera, 3200, 900) > 0);
|
||||
});
|
||||
|
||||
test('with the scope on, a voice tilt stops while the keyhole still shows the planet', (t) => {
|
||||
withScope(t, true);
|
||||
const { camera, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const results = [];
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'up',
|
||||
mode: 'once',
|
||||
});
|
||||
results.push(result.ok);
|
||||
if (result.ok) finishOnce();
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1600, 900, { keyhole: true }) >=
|
||||
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
|
||||
`after tilt ${i + 1}`,
|
||||
);
|
||||
}
|
||||
assert.equal(results[0], true);
|
||||
assert.equal(results.at(-1), false, 'a tilt past the limit is refused');
|
||||
});
|
||||
|
||||
test('with the scope on, a voice turn cannot leave the planet only in the masked corners', (t) => {
|
||||
// An oblique orbit view: turning in place swings the planet toward a corner,
|
||||
// where the full frame still shows it but the scope mask hides it.
|
||||
for (const [height, pitch] of [
|
||||
[5_000_000, -70],
|
||||
[3_000_000, -75],
|
||||
[18_000_000, -80],
|
||||
]) {
|
||||
withScope(t, true);
|
||||
const { camera, tick } = harness(t, {
|
||||
height,
|
||||
pitch,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' })
|
||||
.ok,
|
||||
true,
|
||||
);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
|
||||
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1600, 900, { keyhole: true }) >=
|
||||
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
|
||||
`${height / 1000} km at ${pitch}°`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('far out, a voice tilt is either refused or really moves, at display frame rates', (t) => {
|
||||
// A small planet disc: its sampled share wobbles as it crosses the grid.
|
||||
// Whatever the limit decides, "done" must mean the camera turned, and some
|
||||
// of these tilts must be accepted for that to be checked at all.
|
||||
let accepted = 0;
|
||||
for (const [height, pitch] of FAR_OUT_POSES) {
|
||||
withScope(t, true);
|
||||
const { camera, tick } = harness(t, {
|
||||
latitude: 45.37,
|
||||
longitudeDeg: -33.33,
|
||||
height,
|
||||
heading: 89.9,
|
||||
pitch,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 1024,
|
||||
});
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const before = camera.pitch;
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'up',
|
||||
mode: 'once',
|
||||
});
|
||||
if (!result.ok) {
|
||||
assert.match(result.error, /zoom in first/);
|
||||
continue;
|
||||
}
|
||||
accepted += 1;
|
||||
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
|
||||
assert.ok(
|
||||
camera.pitch - before > radians(0.5) - 1e-9,
|
||||
`${height / 1000} km at ${pitch}°: tilt ${i + 1} reported done but moved ${Cesium.Math.toDegrees(camera.pitch - before)}°`,
|
||||
);
|
||||
}
|
||||
assert.ok(surfacePixelShare(camera, 1280, 1024, { keyhole: true }) > 0);
|
||||
}
|
||||
assert.ok(accepted > 0, 'no far-out tilt was accepted');
|
||||
});
|
||||
|
||||
test('a voice turn reported as done turns at least the half degree its check proved', (t) => {
|
||||
// A grazing sliver: the first frame's small step lands in a dip of the
|
||||
// sampled share, which used to stop the turn before it moved at all.
|
||||
for (const scope of [true, false]) {
|
||||
withScope(t, scope);
|
||||
const { camera, tick } = harness(t, {
|
||||
latitude: 20,
|
||||
longitudeDeg: 10,
|
||||
height: 18_000_000,
|
||||
pitch: -58,
|
||||
heading: 37,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const before = Cesium.Cartesian3.clone(camera.directionWC);
|
||||
const result = moveCamera({
|
||||
motion: 'rotate',
|
||||
direction: 'left',
|
||||
mode: 'once',
|
||||
});
|
||||
if (!result.ok) {
|
||||
assert.match(result.error, /globe out of view/);
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60);
|
||||
const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC);
|
||||
assert.ok(
|
||||
turned >= radians(0.5) - 1e-9,
|
||||
`scope ${scope ? 'on' : 'off'}: turned ${Cesium.Math.toDegrees(turned)}°`,
|
||||
);
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0);
|
||||
}
|
||||
});
|
||||
|
||||
/** Track an entity below `camera`, locking it into that entity's frame. */
|
||||
function trackFrom(viewer, camera) {
|
||||
viewer.trackedEntity = { id: 'high-satellite' };
|
||||
const below = Cesium.Cartesian3.fromDegrees(10, 20, 400_000);
|
||||
camera.lookAtTransform(Cesium.Transforms.eastNorthUpToFixedFrame(below));
|
||||
}
|
||||
|
||||
test('a voice turn that releases a tracked camera is checked as released', (t) => {
|
||||
// Voice releases the follow camera inside the navigation that runs the
|
||||
// motion, so the request is judged by the pose the release will leave.
|
||||
let accepted = 0;
|
||||
for (const [scope, direction, motion] of [
|
||||
[true, 'up', 'tilt'],
|
||||
[false, 'up', 'tilt'],
|
||||
[false, 'left', 'rotate'],
|
||||
]) {
|
||||
withScope(t, scope);
|
||||
const { camera, viewer, tick } = harness(t, {
|
||||
latitude: 20,
|
||||
longitudeDeg: 10,
|
||||
height: 18_000_000,
|
||||
pitch: -58,
|
||||
heading: 37,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
trackFrom(viewer, camera);
|
||||
const releaseThenRun = (start) => {
|
||||
viewer.trackedEntity = undefined;
|
||||
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
return start();
|
||||
};
|
||||
const before = Cesium.Cartesian3.clone(camera.directionWC);
|
||||
const result = moveCamera(
|
||||
{ motion, direction, mode: 'once' },
|
||||
releaseThenRun,
|
||||
);
|
||||
const label = `${motion} ${direction}, scope ${scope ? 'on' : 'off'}`;
|
||||
if (!result.ok) {
|
||||
assert.match(result.error, /globe out of view/, label);
|
||||
continue;
|
||||
}
|
||||
accepted += 1;
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60);
|
||||
const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC);
|
||||
assert.ok(turned >= radians(0.5) - 1e-9, `${label}: turned ${turned}`);
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0,
|
||||
`${label}: the planet stays on screen`,
|
||||
);
|
||||
}
|
||||
assert.ok(accepted > 0, 'no released turn was accepted');
|
||||
});
|
||||
|
||||
test('a refused voice turn leaves a tracked camera tracking', (t) => {
|
||||
for (const scope of [true, false]) {
|
||||
withScope(t, scope);
|
||||
const { camera, viewer, tick } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
// Tilt up to the limit untracked, then track from there.
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok)
|
||||
break;
|
||||
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
|
||||
}
|
||||
trackFrom(viewer, camera);
|
||||
const tracked = viewer.trackedEntity;
|
||||
const transform = Cesium.Matrix4.clone(camera.transform);
|
||||
let released = false;
|
||||
const result = moveCamera(
|
||||
{ motion: 'tilt', direction: 'up', mode: 'once' },
|
||||
(start) => {
|
||||
released = true;
|
||||
viewer.trackedEntity = undefined;
|
||||
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
return start();
|
||||
},
|
||||
);
|
||||
const label = `scope ${scope ? 'on' : 'off'}`;
|
||||
assert.equal(result.ok, false, label);
|
||||
assert.match(result.error, /globe out of view/, label);
|
||||
assert.equal(released, false, label);
|
||||
assert.equal(viewer.trackedEntity, tracked, label);
|
||||
assert.ok(Cesium.Matrix4.equals(camera.transform, transform), label);
|
||||
}
|
||||
});
|
||||
|
||||
test('a turn proven at one position is checked again after a release moves the camera', (t) => {
|
||||
withScope(t, false);
|
||||
const { camera, tick } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
// Tilt up to the limit, then check from 4,000 km closer along the same view
|
||||
// direction, where the next tilt fits; the release moves the camera back.
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok)
|
||||
break;
|
||||
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
|
||||
}
|
||||
camera.moveForward(4_000_000);
|
||||
const result = moveCamera(
|
||||
{ motion: 'tilt', direction: 'up', mode: 'once' },
|
||||
(start) => {
|
||||
camera.moveBackward(4_000_000);
|
||||
return start();
|
||||
},
|
||||
);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /globe out of view/);
|
||||
});
|
||||
|
||||
test('the voice frame limit leaves Columbus view alone', (t) => {
|
||||
// At its limit in 3D a further tilt up is refused; the same tilt in Columbus
|
||||
// view is not limited by the frame.
|
||||
withScope(t, false);
|
||||
const { viewer, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok,
|
||||
true,
|
||||
);
|
||||
finishOnce();
|
||||
const refused = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
|
||||
assert.match(refused.error || '', /globe out of view/);
|
||||
viewer.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW;
|
||||
const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
|
||||
assert.doesNotMatch(result.error || '', /globe out of view/);
|
||||
});
|
||||
|
||||
test('voice tilt below the free-look height is not limited by the frame', (t) => {
|
||||
const { camera, finishOnce } = harness(t, { height: 500_000, pitch: -60 });
|
||||
const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
|
||||
assert.equal(result.ok, true);
|
||||
finishOnce();
|
||||
near(camera.pitch, radians(-45), 1e-6, 'full 15° tilt');
|
||||
});
|
||||
@@ -223,11 +223,20 @@ test('validated voice camera destinations share the UI navigation authority faca
|
||||
'move camera',
|
||||
);
|
||||
ordered(move, [
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
|
||||
'if (argsError) return argsError;',
|
||||
'const start = () => {',
|
||||
/const preserveCameraFlight =\s*motion === 'orbit'/,
|
||||
'runNavigation(start, { preserveCameraFlight })',
|
||||
], 'move validation before handoff');
|
||||
const moveArgs = body(
|
||||
cameraVerbs,
|
||||
/export function moveCameraArgsError\(args = \{\}\) \{([\s\S]*?)\n\}/,
|
||||
'move camera argument checks',
|
||||
);
|
||||
assert.ok(
|
||||
moveArgs.includes("if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))"),
|
||||
'unknown motions are refused before handoff',
|
||||
);
|
||||
|
||||
const route = body(
|
||||
cameraVerbs,
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
adjustOrbitRange,
|
||||
getActiveCameraMotion,
|
||||
initCameraVerbs,
|
||||
interruptCameraMotion,
|
||||
moveCamera,
|
||||
} from './cameraVerbs.js';
|
||||
import { createGevActionRunner } from './voice/gevActions.js';
|
||||
|
||||
const radians = Cesium.Math.toRadians;
|
||||
const degrees = Cesium.Math.toDegrees;
|
||||
|
||||
/** Real Cesium camera over London with no renderer: the voice fly-to frame. */
|
||||
function harness(
|
||||
t,
|
||||
{ height = 4000, heading = 0, pitch = -90, pivot = true } = {},
|
||||
) {
|
||||
const realPerformance = globalThis.performance;
|
||||
let nowMs = 0;
|
||||
globalThis.performance = { now: () => nowMs };
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
clientWidth: 1280,
|
||||
clientHeight: 800,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
});
|
||||
const ellipsoid = Cesium.Ellipsoid.WGS84;
|
||||
const scene = {
|
||||
canvas,
|
||||
drawingBufferWidth: 1280,
|
||||
drawingBufferHeight: 800,
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
mapProjection: new Cesium.GeographicProjection(ellipsoid),
|
||||
globe: { ellipsoid },
|
||||
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
pixelRatio: 1,
|
||||
tweens: [],
|
||||
requestRender() {},
|
||||
};
|
||||
const camera = new Cesium.Camera(scene);
|
||||
scene.camera = camera;
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(-0.1276, 51.5072, height),
|
||||
orientation: { heading: radians(heading), pitch: radians(pitch), roll: 0 },
|
||||
});
|
||||
const viewer = {
|
||||
camera,
|
||||
scene,
|
||||
trackedEntity: undefined,
|
||||
trackedEntityChanged: new Cesium.Event(),
|
||||
clock: { onTick: new Cesium.Event() },
|
||||
};
|
||||
// The app's view target: the ground point under the screen centre.
|
||||
const viewTarget = () => {
|
||||
const ray = new Cesium.Ray(camera.positionWC, camera.directionWC);
|
||||
const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid);
|
||||
return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined;
|
||||
};
|
||||
const target = viewTarget();
|
||||
initCameraVerbs(viewer, pivot ? viewTarget : () => undefined);
|
||||
t.after(() => {
|
||||
interruptCameraMotion('test-cleanup');
|
||||
globalThis.performance = realPerformance;
|
||||
});
|
||||
const tick = (seconds = 1 / 30) => {
|
||||
nowMs += seconds * 1000;
|
||||
viewer.clock.onTick.raiseEvent(viewer.clock);
|
||||
};
|
||||
const finish = () => {
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
|
||||
};
|
||||
return { viewer, camera, target, tick, finish, viewTarget };
|
||||
}
|
||||
|
||||
const pitchDeg = (camera) => degrees(camera.pitch);
|
||||
/** Tick until done() holds; a regression fails here instead of hanging. */
|
||||
const tickUntil = (h, done, frames = 600) => {
|
||||
for (let i = 0; i < frames && !done(); i += 1) h.tick();
|
||||
assert.ok(done(), `not reached in ${frames} frames`);
|
||||
};
|
||||
const headingDeg = (camera) =>
|
||||
degrees(Cesium.Math.negativePiToPi(camera.heading));
|
||||
|
||||
/** Metres between the original centre and the ground now under the crosshair. */
|
||||
function centreDrift(h) {
|
||||
return Cesium.Cartesian3.distance(h.viewTarget(), h.target);
|
||||
}
|
||||
|
||||
test('tilt up leaves a top-down view and keeps the place centred', (t) => {
|
||||
const h = harness(t);
|
||||
const result = moveCamera({ motion: 'tilt', direction: 'up' });
|
||||
assert.equal(result.ok, true);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -75) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
|
||||
assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY));
|
||||
});
|
||||
|
||||
test('continuous tilt up from top-down keeps moving', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok,
|
||||
true,
|
||||
);
|
||||
for (let i = 0; i < 30; i += 1) h.tick();
|
||||
assert.equal(getActiveCameraMotion()?.kind, 'tilt');
|
||||
assert.ok(pitchDeg(h.camera) > -82, `pitch ${pitchDeg(h.camera)}`);
|
||||
});
|
||||
|
||||
test('a requested angle tilts to that pitch', (t) => {
|
||||
const h = harness(t);
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.direction, 'up');
|
||||
assert.equal(result.pitchDeg, -45);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -45) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
|
||||
test('a bare tilt settles on the default oblique angle', (t) => {
|
||||
const h = harness(t);
|
||||
const result = moveCamera({ motion: 'tilt' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.pitchDeg, -45);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
|
||||
});
|
||||
|
||||
test('a requested angle can tilt back down toward top-down', (t) => {
|
||||
const h = harness(t, { pitch: -30 });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -80 });
|
||||
assert.equal(result.direction, 'down');
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5);
|
||||
});
|
||||
|
||||
test('requested angles clamp to the tilt limits', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -2 }).pitchDeg, -5);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
|
||||
});
|
||||
|
||||
test('a positive angle is read as the same angle below the horizon', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: 45 }).pitchDeg, -45);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
|
||||
});
|
||||
|
||||
for (const pitch of ['', null, false, ' ', 'steep'])
|
||||
test(`a blank or non-numeric angle ${JSON.stringify(pitch)} is ignored`, (t) => {
|
||||
harness(t);
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: pitch });
|
||||
// No angle and no direction: the default oblique view, never the horizon.
|
||||
assert.equal(result.pitchDeg, -45);
|
||||
});
|
||||
|
||||
test('a requested angle the camera already holds is a no-op', (t) => {
|
||||
harness(t, { pitch: -45 });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.unchanged, true);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
});
|
||||
|
||||
for (const pitch of [-90, -89]) {
|
||||
test(`a top-down view asked for ${pitch}° stays put`, (t) => {
|
||||
// The tool description calls -89 top-down; the clamp must not tilt a
|
||||
// -90° view up 1° and report it as a move up.
|
||||
const h = harness(t);
|
||||
const before = pitchDeg(h.camera);
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: pitch });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.unchanged, true);
|
||||
assert.equal(result.pitchDeg, Math.round(before));
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
assert.equal(pitchDeg(h.camera), before);
|
||||
});
|
||||
}
|
||||
|
||||
test('tilt down from top-down still refuses honestly', (t) => {
|
||||
harness(t);
|
||||
const result = moveCamera({ motion: 'tilt', direction: 'down' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /straight down/);
|
||||
});
|
||||
|
||||
test('a tilt up nudge stops at the horizon limit', (t) => {
|
||||
const h = harness(t, { pitch: -12 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
|
||||
});
|
||||
|
||||
test('without a ground pivot a tilt still leaves top-down in place', (t) => {
|
||||
const h = harness(t, { pivot: false });
|
||||
const before = Cesium.Cartesian3.clone(h.camera.positionWC);
|
||||
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -75) < 0.5);
|
||||
assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-6);
|
||||
});
|
||||
|
||||
for (const heading of [0, 30, -120])
|
||||
test(`tilt from top-down keeps the ${heading}° heading`, (t) => {
|
||||
const h = harness(t, { heading, height: 67000 });
|
||||
// Top-down float noise must not pick a new heading for the pivot.
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(headingDeg(h.camera) - heading) < 1,
|
||||
`heading ${headingDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(centreDrift(h) < 50, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
|
||||
test('an oblique tilt keeps its heading toward the pivot', (t) => {
|
||||
const h = harness(t, { heading: 75, pitch: -60 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -30 }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(headingDeg(h.camera) - 75) < 0.5);
|
||||
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
|
||||
test('a pivoting tilt yields when tracking takes the camera', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
|
||||
h.tick();
|
||||
const entity = { id: 'tracked' };
|
||||
h.viewer.trackedEntity = entity;
|
||||
h.viewer.trackedEntityChanged.raiseEvent(entity);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
});
|
||||
|
||||
/** Metres between the camera and the point it pivots about. */
|
||||
const pivotRange = (h) =>
|
||||
Cesium.Cartesian3.distance(h.camera.positionWC, h.target);
|
||||
const heightM = (camera) =>
|
||||
Cesium.Cartographic.fromCartesian(camera.positionWC).height;
|
||||
|
||||
test('a zoom during a pivoting tilt changes the range it holds', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok,
|
||||
true,
|
||||
);
|
||||
h.tick();
|
||||
const before = pivotRange(h);
|
||||
assert.deepEqual(
|
||||
adjustOrbitRange(0.5),
|
||||
{ kind: 'tilt', changed: true },
|
||||
'the tilt must take the zoom',
|
||||
);
|
||||
for (let i = 0; i < 5; i += 1) h.tick();
|
||||
// The next frame's lookAt must not put the old range back.
|
||||
assert.ok(
|
||||
Math.abs(pivotRange(h) - before / 2) < 1,
|
||||
`range ${pivotRange(h)} m, wanted ${before / 2} m`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a zoom with no pivot to hold is left to the camera', (t) => {
|
||||
harness(t, { pivot: false });
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
assert.equal(adjustOrbitRange(0.5), null);
|
||||
});
|
||||
|
||||
test('a pivot tilt toward the horizon keeps its clearance above the ground', (t) => {
|
||||
// A close top-down view: swinging 250 m of range to -5° would end ~22 m up.
|
||||
const h = harness(t, { height: 250 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -5 }).ok, true);
|
||||
let lowest = Infinity;
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
lowest = Math.min(lowest, heightM(h.camera));
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
|
||||
assert.ok(lowest > 149, `camera dropped to ${lowest} m`);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY));
|
||||
});
|
||||
|
||||
test('a continuous tilt up from close in keeps its clearance', (t) => {
|
||||
const h = harness(t, { height: 250 });
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
|
||||
}
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
|
||||
});
|
||||
|
||||
test('a city-scale pivot tilt keeps the place centred to the horizon limit', (t) => {
|
||||
const h = harness(t);
|
||||
moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
h.finish();
|
||||
// 4 km of range clears the floor at every pitch: all pivot, no drift.
|
||||
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
|
||||
test('pitchDeg against a contradicting direction is refused', (t) => {
|
||||
harness(t, { pitch: -45 });
|
||||
// "tilt down 10 degrees": -10° is an absolute angle, which is up from -45°.
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'down',
|
||||
pitchDeg: 10,
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /absolute angle/);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
});
|
||||
|
||||
test('pitchDeg with a direction that agrees still tilts', (t) => {
|
||||
const h = harness(t, { pitch: -45 });
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'down',
|
||||
pitchDeg: -80,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.direction, 'down');
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5);
|
||||
});
|
||||
|
||||
test('the pitchDeg and direction check uses the camera pitch', (t) => {
|
||||
// At regional range the Earth curves away and the pivot pitch is a degree
|
||||
// or two shallower than the camera's (-30°); -29° is still up from it.
|
||||
harness(t, { height: 150000, pitch: -30 });
|
||||
const down = moveCamera({ motion: 'tilt', direction: 'down', pitchDeg: -29 });
|
||||
assert.equal(down.ok, false);
|
||||
assert.match(down.error, /tilts up, not down/);
|
||||
const up = moveCamera({ motion: 'tilt', direction: 'up', pitchDeg: -29 });
|
||||
assert.equal(up.ok, true);
|
||||
assert.equal(up.direction, 'up');
|
||||
});
|
||||
|
||||
test('after a zoom in, a pivoting tilt never lowers the camera further', (t) => {
|
||||
const h = harness(t, { height: 1000 });
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
tickUntil(h, () => pitchDeg(h.camera) >= -30);
|
||||
adjustOrbitRange(1 / 2.4);
|
||||
adjustOrbitRange(1 / 2.4);
|
||||
h.tick();
|
||||
const afterZoom = heightM(h.camera);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
assert.ok(
|
||||
heightM(h.camera) > afterZoom - 1,
|
||||
`camera fell from ${afterZoom} m to ${heightM(h.camera)} m`,
|
||||
);
|
||||
}
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
|
||||
});
|
||||
|
||||
test('a zoom in during a close pivot tilt never pushes the camera out', (t) => {
|
||||
// 62 m of range, under the 80 m zoom floor.
|
||||
const h = harness(t, { height: 40, pitch: -40 });
|
||||
moveCamera({ motion: 'tilt', direction: 'down', mode: 'continuous' });
|
||||
h.tick();
|
||||
const before = pivotRange(h);
|
||||
assert.ok(before < 80, `range ${before} m`);
|
||||
adjustOrbitRange(1 / 1.6);
|
||||
h.tick();
|
||||
assert.ok(pivotRange(h) <= before + 0.01, `range grew to ${pivotRange(h)} m`);
|
||||
});
|
||||
|
||||
test('a zoom out during a far orbit never pulls the camera in', (t) => {
|
||||
// 15,000 km up, beyond the 5,000 km zoom cap.
|
||||
const h = harness(t, { height: 15_000_000 });
|
||||
assert.equal(moveCamera({ motion: 'orbit', mode: 'continuous' }).ok, true);
|
||||
h.tick();
|
||||
const before = pivotRange(h);
|
||||
assert.ok(before > 5_000_000, `range ${before} m`);
|
||||
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'orbit', changed: false });
|
||||
h.tick();
|
||||
assert.ok(pivotRange(h) >= before - 1, `range fell to ${pivotRange(h)} m`);
|
||||
});
|
||||
|
||||
test('an out-of-range pitchDeg is clamped to the tilt limits', (t) => {
|
||||
// The schema carries no range; 120 reads as 120° below the horizon.
|
||||
const h = harness(t, { pitch: -45 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: 120 }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -89) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a zoom in during a pivot tilt stops at the clearance', (t) => {
|
||||
// 400 m of range at -40°, then "zoom in a lot" twice: unfloored, that left
|
||||
// the camera about 107 m up, then at the 80 m zoom floor.
|
||||
const h = harness(t, { height: 400 });
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
tickUntil(h, () => pitchDeg(h.camera) >= -40);
|
||||
assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: true });
|
||||
// At -40° the floor is 150 / sin 40° ≈ 233 m, which the first zoom reached.
|
||||
assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: false });
|
||||
// The camera must take that range, not just report it.
|
||||
h.tick();
|
||||
const floor = 150 / Math.sin(radians(40));
|
||||
assert.ok(
|
||||
Math.abs(pivotRange(h) - floor) < floor * 0.02,
|
||||
`range ${pivotRange(h)} m, wanted about ${floor} m`,
|
||||
);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
|
||||
}
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
|
||||
});
|
||||
|
||||
test('a voice zoom during a pivot tilt says what it changed', async (t) => {
|
||||
const h = harness(t, { height: 400 });
|
||||
const runner = createGevActionRunner({
|
||||
viewer: h.viewer,
|
||||
styleManager: {},
|
||||
dataManager: { layers: new Map(), getAll: () => [] },
|
||||
});
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
h.tick();
|
||||
const zoom = { direction: 'in', amount: 'lot' };
|
||||
const first = await runner('adjust_camera_zoom', zoom);
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.tiltRangeAdjusted, true);
|
||||
assert.equal('orbitRadiusAdjusted' in first, false);
|
||||
// 400 m → 167 m → the 150 m clearance; a third zoom in moves nothing, and
|
||||
// says so.
|
||||
assert.equal((await runner('adjust_camera_zoom', zoom)).ok, true);
|
||||
const third = await runner('adjust_camera_zoom', zoom);
|
||||
assert.equal(third.ok, false);
|
||||
assert.match(third.error, /tilt is already at its closest range/);
|
||||
});
|
||||
|
||||
test('from globe range a tilt happens in place', (t) => {
|
||||
// A pivot 2,000 km out would swing the camera thousands of km.
|
||||
const h = harness(t, { height: 2_000_000 });
|
||||
const before = Cesium.Cartesian3.clone(h.camera.positionWC);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
|
||||
assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-3);
|
||||
});
|
||||
|
||||
for (const height of [100_000, 400_000])
|
||||
test(`from ${height / 1000} km a tilt to -5° ends with the camera at -5°`, (t) => {
|
||||
// The pivot pitch is shallower than the camera's by up to 4° at this
|
||||
// range; the camera reached only -5.9° and -8.6° when the swing stopped
|
||||
// at a pivot pitch of -5°.
|
||||
const h = harness(t, { height });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
|
||||
assert.equal(result.pitchDeg, -5);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
|
||||
const nudge = moveCamera({ motion: 'tilt', direction: 'up' });
|
||||
assert.equal(nudge.ok, false);
|
||||
assert.match(nudge.error, /upper tilt limit/);
|
||||
});
|
||||
|
||||
// From 550 km out the camera's -5° lies beyond the place's own horizon: the
|
||||
// swing ran into its clearance, let go of the pivot and tilted the rest in
|
||||
// place, which put the crosshair on the sky (28 to 63 km up), and a return
|
||||
// to top-down then landed 600 to 900 km from the place.
|
||||
for (const height of [600_000, 700_000, 900_000]) {
|
||||
test(`from ${height / 1000} km a tilt to -5° keeps the place centred`, (t) => {
|
||||
const h = harness(t, { height });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
|
||||
assert.equal(result.ok, true);
|
||||
h.finish();
|
||||
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
|
||||
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
|
||||
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
|
||||
// The reply names the angle the camera reached, not the one asked for.
|
||||
assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5,
|
||||
`camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`,
|
||||
);
|
||||
assert.match(result.note, /horizon/);
|
||||
const nudge = moveCamera({ motion: 'tilt', direction: 'up' });
|
||||
assert.equal(nudge.ok, false);
|
||||
assert.match(nudge.error, /upper tilt limit/);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -89 }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
|
||||
// Back over the place at the range it began with (-89° is ~1° off nadir).
|
||||
const range = Cesium.Cartesian3.distance(h.camera.positionWC, h.target);
|
||||
assert.ok(
|
||||
Math.abs(range - height) < height * 0.01,
|
||||
`top-down ${range} m from the place`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`a continuous tilt up from ${height / 1000} km stops with the place centred`, (t) => {
|
||||
const h = harness(t, { height });
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
for (let i = 0; i < 1200 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
assert.ok(h.viewTarget(), `the crosshair is on the sky at frame ${i}`);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must stop at its limit');
|
||||
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
}
|
||||
|
||||
test('a pitchDeg tilt toward the horizon limit says it is already there', (t) => {
|
||||
const h = harness(t, { height: 900_000 });
|
||||
moveCamera({ motion: 'tilt', pitchDeg: 5 });
|
||||
h.finish();
|
||||
const again = moveCamera({ motion: 'tilt', pitchDeg: 5 });
|
||||
assert.equal(again.ok, false);
|
||||
assert.match(again.error, /upper tilt limit/);
|
||||
});
|
||||
|
||||
test('a regional tilt-up nudge goes to the camera horizon limit', (t) => {
|
||||
// 20 km up at -7°: the pivot 186 km out sits at -5.3°, but the camera can
|
||||
// still tilt up 2°.
|
||||
const h = harness(t, { height: 20000, pitch: -7 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a voice tilt from a tracked view judges its limit from the camera pitch', (t) => {
|
||||
const h = harness(t, { height: 20000, pitch: -5.2 });
|
||||
h.viewer.trackedEntity = { id: 'tracked' };
|
||||
// Voice releases the follow camera before the motion starts.
|
||||
const release = (start) => {
|
||||
h.viewer.trackedEntity = undefined;
|
||||
return start();
|
||||
};
|
||||
const result = moveCamera({ motion: 'tilt', direction: 'up' }, release);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /upper tilt limit/);
|
||||
});
|
||||
|
||||
test('a zoom out during a pivoting tilt stops at the pivot range limit', (t) => {
|
||||
// 900 km top-down, "tilt to 10°", then "zoom out a lot" twice: capped at
|
||||
// the orbit's 5,000 km, the swing reached 2,386 km up at a real -44°.
|
||||
const h = harness(t, { height: 900_000 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -10 }).ok, true);
|
||||
h.tick();
|
||||
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: true });
|
||||
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: false });
|
||||
let farthest = 0;
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
farthest = Math.max(farthest, pivotRange(h));
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
|
||||
assert.ok(farthest <= 1_000_001, `swung ${farthest} m from the pivot`);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -10) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('an unchanged angle still stops a running motion through navigation', (t) => {
|
||||
const h = harness(t);
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
tickUntil(h, () => pitchDeg(h.camera) >= -45.3);
|
||||
let handoffs = 0;
|
||||
const navigate = (start) => {
|
||||
handoffs += 1;
|
||||
return start();
|
||||
};
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 }, navigate);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.unchanged, true);
|
||||
assert.equal(handoffs, 1, 'the request must take navigation');
|
||||
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
|
||||
const held = pitchDeg(h.camera);
|
||||
for (let i = 0; i < 30; i += 1) h.tick();
|
||||
assert.equal(pitchDeg(h.camera), held);
|
||||
});
|
||||
|
||||
for (const direction of ['left', 'right'])
|
||||
test(`a tilt ${direction} is refused`, (t) => {
|
||||
harness(t, { pitch: -45 });
|
||||
const result = moveCamera({ motion: 'tilt', direction });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /tilt needs up or down/);
|
||||
const angled = moveCamera({ motion: 'tilt', direction, pitchDeg: -30 });
|
||||
assert.equal(angled.ok, false);
|
||||
assert.match(angled.error, /pitchDeg alone/);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
});
|
||||
|
||||
// The 1,000 km pivot cutoff includes its boundary: at exactly 1,000 km the
|
||||
// pivot range came back a hair over it, so the tilt ran in place and put the
|
||||
// crosshair on the sky.
|
||||
test('from exactly 1,000 km a tilt to -5° still pivots about the place', (t) => {
|
||||
const h = harness(t, { height: 1_000_000 });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
|
||||
assert.equal(result.ok, true);
|
||||
h.finish();
|
||||
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
|
||||
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5,
|
||||
`camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`,
|
||||
);
|
||||
assert.match(result.note, /horizon/);
|
||||
});
|
||||
|
||||
// A zoom mid-tilt moves the pivot's horizon limit: the zoom's reply names the
|
||||
// angle the tilt now ends at, and the camera ends there.
|
||||
test('a zoom out during a tilt to an angle reports the angle it now ends at', (t) => {
|
||||
const h = harness(t, { height: 400_000 });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
assert.equal(result.pitchDeg, -5);
|
||||
assert.equal(result.note, undefined);
|
||||
for (let i = 0; i < 10; i += 1) h.tick();
|
||||
const zoom = adjustOrbitRange(2.5);
|
||||
assert.equal(zoom.kind, 'tilt');
|
||||
assert.equal(zoom.changed, true);
|
||||
h.finish();
|
||||
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
|
||||
assert.ok(zoom.pitchDeg < -5, `zoom reported ${zoom.pitchDeg}°`);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5,
|
||||
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
|
||||
);
|
||||
assert.match(zoom.note, /horizon/);
|
||||
});
|
||||
|
||||
test('a zoom in during a horizon-limited tilt reports the angle it now reaches', (t) => {
|
||||
const h = harness(t, { height: 900_000 });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`);
|
||||
for (let i = 0; i < 10; i += 1) h.tick();
|
||||
const zoom = adjustOrbitRange(1 / 2.4);
|
||||
assert.equal(zoom.changed, true);
|
||||
h.finish();
|
||||
assert.equal(zoom.pitchDeg, -5);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
|
||||
`camera at ${pitchDeg(h.camera)}°`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a voice zoom during a tilt to an angle passes on the new end angle', async (t) => {
|
||||
const h = harness(t, { height: 400_000 });
|
||||
const runner = createGevActionRunner({
|
||||
viewer: h.viewer,
|
||||
styleManager: {},
|
||||
dataManager: { layers: new Map(), getAll: () => [] },
|
||||
});
|
||||
moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
for (let i = 0; i < 10; i += 1) h.tick();
|
||||
const zoom = await runner('adjust_camera_zoom', {
|
||||
direction: 'out',
|
||||
amount: 'lot',
|
||||
});
|
||||
assert.equal(zoom.ok, true);
|
||||
assert.equal(zoom.tiltRangeAdjusted, true);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5,
|
||||
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
|
||||
);
|
||||
assert.match(zoom.note, /horizon/);
|
||||
});
|
||||
|
||||
// Late in the tilt the camera is already past the angle the new range's
|
||||
// horizon allows (from 400 km at -8°, a zoom out x2.5 reported -8°): past it
|
||||
// the place is behind the Earth, so the camera steps down to that limit with
|
||||
// the zoom, then holds it with no further jump, and the zoom's reply names
|
||||
// the angle it ends at, not the one it stood at.
|
||||
test('a late zoom out during a tilt steps to the new limit and reports it', (t) => {
|
||||
const h = harness(t, { height: 400_000 });
|
||||
moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
for (let i = 0; i < 70; i += 1) h.tick();
|
||||
const zoom = adjustOrbitRange(2.5);
|
||||
assert.equal(zoom.changed, true);
|
||||
h.tick();
|
||||
assert.equal(zoom.pitchDeg, Math.round(pitchDeg(h.camera)));
|
||||
let last = pitchDeg(h.camera);
|
||||
let maxStep = 0;
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
maxStep = Math.max(maxStep, Math.abs(pitchDeg(h.camera) - last));
|
||||
last = pitchDeg(h.camera);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
|
||||
assert.ok(maxStep < 0.1, `pitch jumped ${maxStep}° in one frame`);
|
||||
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
|
||||
// Replies give whole degrees: the reported angle is the reached one rounded.
|
||||
assert.equal(
|
||||
zoom.pitchDeg,
|
||||
Math.round(pitchDeg(h.camera)),
|
||||
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
|
||||
);
|
||||
assert.match(zoom.note, /horizon/);
|
||||
});
|
||||
|
||||
// Easing from the camera's old pitch at the zoomed-out range put the camera
|
||||
// below the place's horizon: the first frame's crosshair hit the ground
|
||||
// 186 km from London, and London stayed hidden for ~11 frames.
|
||||
test('a late zoom out during a tilt keeps the place under the crosshair', (t) => {
|
||||
const h = harness(t, { height: 400_000 });
|
||||
moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
for (let i = 0; i < 70; i += 1) h.tick();
|
||||
const zoom = adjustOrbitRange(2.5);
|
||||
assert.equal(zoom.changed, true);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
const hit = h.viewTarget();
|
||||
assert.ok(hit, `frame ${i}: the crosshair is on the sky`);
|
||||
const drift = centreDrift(h);
|
||||
assert.ok(
|
||||
drift < 100,
|
||||
`frame ${i}: crosshair ${Math.round(drift)} m off the place`,
|
||||
);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
|
||||
assert.ok(
|
||||
Math.abs(pivotRange(h) - 1_000_000) < 1,
|
||||
`range ${pivotRange(h)} m`,
|
||||
);
|
||||
assert.equal(
|
||||
zoom.pitchDeg,
|
||||
Math.round(pitchDeg(h.camera)),
|
||||
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
|
||||
);
|
||||
assert.match(zoom.note, /horizon/);
|
||||
});
|
||||
|
||||
// The zoom out left the pivot pitch level with the place, so the zoom in's
|
||||
// clearance floor came out infinite and it was refused at 1,000 km.
|
||||
test('a zoom in right after a late zoom out brings the tilt back in', (t) => {
|
||||
const h = harness(t, { height: 400_000 });
|
||||
moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
for (let i = 0; i < 70; i += 1) h.tick();
|
||||
const range = pivotRange(h);
|
||||
assert.equal(adjustOrbitRange(2.5).changed, true);
|
||||
const zoomIn = adjustOrbitRange(0.4);
|
||||
assert.equal(zoomIn.changed, true, 'the zoom in must not be refused');
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pivotRange(h) - range) < 1,
|
||||
`range ${pivotRange(h)} m, wanted ${range} m`,
|
||||
);
|
||||
assert.ok(
|
||||
centreDrift(h) < 100,
|
||||
`crosshair ${centreDrift(h)} m off the place`,
|
||||
);
|
||||
});
|
||||
+748
-83
@@ -20,11 +20,19 @@
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { GLOBE_VIEW } from './locations.js';
|
||||
import {
|
||||
globeFrameShare,
|
||||
isFreeGlobeCamera,
|
||||
lookAngleWithinFrame,
|
||||
lookStepLosesGlobe,
|
||||
} from './globeInView.js';
|
||||
import {
|
||||
projectEarthDiscToViewport,
|
||||
earthDiscScreenRadius,
|
||||
getKeyholeGeometry,
|
||||
KEYHOLE_OUTER_RADIUS,
|
||||
} from './celestialRing.js';
|
||||
import { isScopeMaskEnabled } from './scopeMask.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
@@ -48,6 +56,33 @@ const ONCE = {
|
||||
};
|
||||
const PITCH_MIN = Cesium.Math.toRadians(-89);
|
||||
const PITCH_MAX = Cesium.Math.toRadians(-5);
|
||||
/** A bare "tilt the camera" / "oblique view" settles here. */
|
||||
const TILT_DEFAULT_PITCH_DEG = -45;
|
||||
/** °/s for a tilt to a requested angle; short ones keep the once duration. */
|
||||
const TILT_TO_DEG_S = 30;
|
||||
/**
|
||||
* A pivoting tilt keeps the range, so its height above the pivot is
|
||||
* range × sin|pitch|. It swings no lower than this; the rest tilts in place.
|
||||
*/
|
||||
const TILT_MIN_CLEARANCE_M = 150;
|
||||
/**
|
||||
* Beyond this range a pivot would swing the camera thousands of km about the
|
||||
* crosshair point, so the tilt happens in place instead.
|
||||
*/
|
||||
const TILT_PIVOT_MAX_RANGE_M = 1_000_000;
|
||||
/**
|
||||
* A pivot range rebuilt from the camera's position comes back a hair off the
|
||||
* range it was set at: a range this close past the cutoff still counts as on
|
||||
* it.
|
||||
*/
|
||||
const TILT_PIVOT_RANGE_SLACK_M = 1;
|
||||
/**
|
||||
* The shallowest a pivot swing goes when its clearance is not what stops it.
|
||||
* From several hundred km the camera's own horizon limit lies past the
|
||||
* place's horizon; tilting the rest in place there would lift the crosshair
|
||||
* onto the sky, so the swing stops with the place this far above it.
|
||||
*/
|
||||
const TILT_MIN_PIVOT_ELEVATION = Cesium.Math.toRadians(0.5);
|
||||
|
||||
/* ── Route dolly: cinematic tuning ──────────────────────────────────────────
|
||||
* Every knob an owner may want to retune lives in this block. The shaping is
|
||||
@@ -966,6 +1001,17 @@ let _trackingRemover = null;
|
||||
let _inputRemovers = [];
|
||||
/** Monotonic id for the motion currently running; 0 means "no motion". */
|
||||
let _motionSeq = 0;
|
||||
/** Canvas grabs, touches and wheel turns since load. */
|
||||
let _manualInputCount = 0;
|
||||
|
||||
/**
|
||||
* Lets a caller holding a motion back (behind a fly-to) see whether the user
|
||||
* took the camera meanwhile; Cesium does not cancel a flight on input.
|
||||
* @returns {number} Manual camera inputs so far.
|
||||
*/
|
||||
export function manualCameraInputCount() {
|
||||
return _manualInputCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a motion and stamp it with a fresh id. The id is what lets a caller
|
||||
@@ -1008,6 +1054,187 @@ export function interruptCameraMotionIfActive(
|
||||
return interruptCameraMotion(reason);
|
||||
}
|
||||
|
||||
// The frame share that in-place voice turns measure their limit against, with
|
||||
// the pose the last voice turn left the camera in. A turn that starts where
|
||||
// the last one left off keeps that baseline (raised if the planet now fills
|
||||
// more of the frame), so repeating a request cannot take a little more each
|
||||
// time. Once anything else has moved or turned the camera, a turn starts over
|
||||
// from what is on screen.
|
||||
let _lookBaseline = null;
|
||||
|
||||
// The pre-check's proof that a half-degree turn from this pose keeps the planet
|
||||
// in view: which way, from which position and direction. A motion that starts from that
|
||||
// pose turns at least that far, so a request reported as done really moves
|
||||
// even when a smaller first step lands in a dip of the sampled share.
|
||||
const PROVEN_TURN_RAD = Cesium.Math.toRadians(0.5);
|
||||
let _provenLook = null;
|
||||
|
||||
/** The part of the frame a viewer can see: the scope keyhole when it is on. */
|
||||
function visibleFrame() {
|
||||
return { keyholeRadius: isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0 };
|
||||
}
|
||||
|
||||
/** The frame share an in-place turn from `cam`'s pose is limited against. */
|
||||
function lookBaselineShare(cam) {
|
||||
const share = globeFrameShare(cam, visibleFrame());
|
||||
const continues =
|
||||
_lookBaseline &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
_lookBaseline.position,
|
||||
cam.positionWC,
|
||||
0,
|
||||
1,
|
||||
) &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
_lookBaseline.direction,
|
||||
cam.directionWC,
|
||||
0,
|
||||
1e-9,
|
||||
);
|
||||
_lookBaseline = {
|
||||
position: Cesium.Cartesian3.clone(cam.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(cam.directionWC),
|
||||
share: continues ? Math.max(_lookBaseline.share, share) : share,
|
||||
};
|
||||
return _lookBaseline.share;
|
||||
}
|
||||
|
||||
/**
|
||||
* One in-place look step of motion `m`. From orbit a free globe camera turns
|
||||
* only as far as keeps the planet in frame, measured against the baseline the
|
||||
* turn started from, then the motion ends there.
|
||||
*/
|
||||
function lookWithinFrame(cam, m, look, angle) {
|
||||
let allowed = angle;
|
||||
if (isFreeGlobeCamera(_viewer)) {
|
||||
if (m.globeShareAtStart === undefined) {
|
||||
m.globeShareAtStart = lookBaselineShare(cam);
|
||||
m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0;
|
||||
m.turnedRad = 0;
|
||||
}
|
||||
_provenLook = null;
|
||||
allowed = lookAngleWithinFrame(cam, look, angle, {
|
||||
...visibleFrame(),
|
||||
startShare: m.globeShareAtStart,
|
||||
});
|
||||
const owed = Math.min(angle, m.provenTurnRad - m.turnedRad);
|
||||
if (allowed < owed) allowed = owed;
|
||||
m.turnedRad += allowed;
|
||||
}
|
||||
if (allowed > 0) cam[look](allowed);
|
||||
// The pose this turn leaves, for a turn that continues from it.
|
||||
if (_lookBaseline)
|
||||
Cesium.Cartesian3.clone(cam.directionWC, _lookBaseline.direction);
|
||||
if (allowed < angle) interruptCameraMotion('globe-out-of-frame');
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-place look a pitch change from `fromPitch` to `goalPitch` makes:
|
||||
* 'lookUp', 'lookDown', or null when it does not move. A tilt is judged by
|
||||
* the way its pitch moves, whatever form or direction word it came in.
|
||||
*/
|
||||
function tiltLook(fromPitch, goalPitch) {
|
||||
if (!Number.isFinite(fromPitch) || !Number.isFinite(goalPitch)) return null;
|
||||
if (goalPitch > fromPitch) return 'lookUp';
|
||||
return goalPitch < fromPitch ? 'lookDown' : null;
|
||||
}
|
||||
|
||||
/** Tilt `m` in place to `goalPitch`, as far as the frame limit allows. */
|
||||
function tiltInPlaceWithinFrame(cam, m, goalPitch) {
|
||||
const look = tiltLook(cam.pitch, goalPitch);
|
||||
if (look) lookWithinFrame(cam, m, look, Math.abs(goalPitch - cam.pitch));
|
||||
}
|
||||
|
||||
/**
|
||||
* The pitch an in-place tilt from `cam`'s pose toward `goalPitch` reaches
|
||||
* before the frame limit stops it, judged as its steps will be: `goalPitch`
|
||||
* itself when the planet stays in frame the whole way, or the camera is not a
|
||||
* free globe camera. The steps run on a copy of the camera, each looking by
|
||||
* the pitch still to go as the tilt's frames do, because a rolled camera's
|
||||
* look turns its pitch by less than the look angle.
|
||||
*/
|
||||
function tiltPitchWithinFrame(cam, goalPitch) {
|
||||
const look = tiltLook(cam.pitch, goalPitch);
|
||||
if (!look || !isFreeGlobeCamera(_viewer)) return goalPitch;
|
||||
const frame = { ...visibleFrame(), startShare: lookBaselineShare(cam) };
|
||||
// A proven half degree is turned whatever the sampled share says.
|
||||
let owed = lookProven(cam, look) ? PROVEN_TURN_RAD : 0;
|
||||
const probe = Cesium.Camera.clone(cam);
|
||||
for (let i = 0; i < 32; i += 1) {
|
||||
if (tiltLook(probe.pitch, goalPitch) !== look) return goalPitch;
|
||||
const angle = Math.abs(goalPitch - probe.pitch);
|
||||
if (angle < 1e-6) return goalPitch;
|
||||
const allowed = Math.max(
|
||||
lookAngleWithinFrame(probe, look, angle, frame),
|
||||
Math.min(angle, owed),
|
||||
);
|
||||
owed -= allowed;
|
||||
if (allowed > 0) probe[look](allowed);
|
||||
if (allowed < angle) return probe.pitch;
|
||||
}
|
||||
return probe.pitch;
|
||||
}
|
||||
|
||||
/** The reply's note for a tilt the frame limit stops short of its angle. */
|
||||
function tiltFrameNote(pitch) {
|
||||
return `Stopped at ${Math.round(Cesium.Math.toDegrees(pitch))}°: from this height a shallower angle would turn the globe out of view. Zoom in to tilt further.`;
|
||||
}
|
||||
|
||||
/** True when the pre-check proved a half-degree `look` from `cam`'s pose. */
|
||||
function lookProven(cam, look) {
|
||||
return (
|
||||
_provenLook?.look === look &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
_provenLook.position,
|
||||
cam.positionWC,
|
||||
0,
|
||||
1,
|
||||
) &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
_provenLook.direction,
|
||||
cam.directionWC,
|
||||
0,
|
||||
1e-9,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The check a voice in-place turn (`look`: 'lookUp', 'lookDown', 'lookLeft'
|
||||
* or 'lookRight') must pass from orbit: refused when even half a degree would
|
||||
* cross the frame limit, otherwise proven for that half degree from this pose.
|
||||
* With `released`, the turn runs through a navigation that first releases a
|
||||
* tracked or locked camera and keeps its world pose. The frame measure and the
|
||||
* turn are the same in any reference frame, so such a camera is judged as it
|
||||
* is, before the release, and a refusal leaves tracking as it was. Otherwise a
|
||||
* camera that is not a free globe camera is not checked. Returns the refusal,
|
||||
* or null.
|
||||
*/
|
||||
function checkLookFromOrbit(look, { released = false } = {}) {
|
||||
_provenLook = null;
|
||||
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null;
|
||||
const cam = _viewer.camera;
|
||||
if (!released && !isFreeGlobeCamera(_viewer)) return null;
|
||||
if (
|
||||
lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, {
|
||||
...visibleFrame(),
|
||||
startShare: lookBaselineShare(cam),
|
||||
})
|
||||
)
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error:
|
||||
'From this height that would turn the globe out of view — zoom in first, or turn the other way.',
|
||||
};
|
||||
_provenLook = {
|
||||
look,
|
||||
position: Cesium.Cartesian3.clone(cam.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(cam.directionWC),
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearLookAt() {
|
||||
try {
|
||||
_viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
@@ -1052,7 +1279,9 @@ export function interruptCameraMotion(reason = 'interrupt') {
|
||||
if (
|
||||
wasActive &&
|
||||
!tracking &&
|
||||
(_active.kind === 'orbit' || _active.kind === 'route')
|
||||
(_active.kind === 'orbit' ||
|
||||
_active.kind === 'route' ||
|
||||
(_active.kind === 'tilt' && _active.hpr))
|
||||
)
|
||||
clearLookAt();
|
||||
// Instant, not eased: the user grabbed the camera, or the flight is over.
|
||||
@@ -1104,7 +1333,7 @@ function onTick() {
|
||||
let scale = 1;
|
||||
if (m.mode === 'once') {
|
||||
m.elapsed = (m.elapsed || 0) + dt;
|
||||
const t = Math.min(1, m.elapsed / ONCE.durationS);
|
||||
const t = Math.min(1, m.elapsed / (m.durationS || ONCE.durationS));
|
||||
scale = 1 - (1 - t) * (1 - t); // ease-out progress share
|
||||
if (t >= 1) {
|
||||
interruptCameraMotion('once-complete');
|
||||
@@ -1173,30 +1402,20 @@ function onTick() {
|
||||
else if (m.direction === 'right') cam.moveRight(step);
|
||||
else if (m.direction === 'up') cam.moveUp(step);
|
||||
else cam.moveDown(step);
|
||||
} else if (
|
||||
m.kind === 'tilt' ||
|
||||
m.kind === 'rotate' ||
|
||||
m.kind === 'rotate_globe'
|
||||
) {
|
||||
} else if (m.kind === 'tilt') {
|
||||
tickTilt(cam, m, scale, dt);
|
||||
} else if (m.kind === 'rotate' || m.kind === 'rotate_globe') {
|
||||
const degS = TILT_DEG_S[m.speed];
|
||||
const stepRad =
|
||||
m.mode === 'once'
|
||||
? Cesium.Math.toRadians(
|
||||
m.kind === 'tilt' ? ONCE.tiltDeg : ONCE.rotateDeg,
|
||||
) *
|
||||
(scale - (m.lastScale || 0))
|
||||
? Cesium.Math.toRadians(ONCE.rotateDeg) * (scale - (m.lastScale || 0))
|
||||
: Cesium.Math.toRadians(degS) * dt;
|
||||
m.lastScale = scale;
|
||||
if (m.kind === 'rotate_globe') {
|
||||
// Tracking can acquire the camera without going through navigation.
|
||||
// Yield without resetting its pose/frame: the world-space arc proof
|
||||
// only describes Cesium's rotation while the frame is identity.
|
||||
if (
|
||||
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
|
||||
_viewer.trackedEntity ||
|
||||
_isCockpitActive?.() ||
|
||||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
|
||||
) {
|
||||
if (!isFreeGlobeCamera(_viewer) || _isCockpitActive?.()) {
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
return;
|
||||
}
|
||||
@@ -1218,18 +1437,14 @@ function onTick() {
|
||||
return;
|
||||
}
|
||||
cam.rotate(m.rotationAxis, angle);
|
||||
} else if (m.kind === 'tilt') {
|
||||
const up = m.direction === 'up';
|
||||
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
|
||||
const next = cam.pitch + (up ? stepRad : -stepRad);
|
||||
if (next > PITCH_MAX || next < PITCH_MIN) {
|
||||
interruptCameraMotion('tilt-clamp');
|
||||
return;
|
||||
}
|
||||
if (up) cam.lookUp(stepRad);
|
||||
else cam.lookDown(stepRad);
|
||||
} else if (m.direction === 'left') cam.lookLeft(stepRad);
|
||||
else cam.lookRight(stepRad);
|
||||
} else {
|
||||
lookWithinFrame(
|
||||
cam,
|
||||
m,
|
||||
m.direction === 'left' ? 'lookLeft' : 'lookRight',
|
||||
stepRad,
|
||||
);
|
||||
}
|
||||
} else if (m.kind === 'route') {
|
||||
// The dolly's shaping lives in advanceRouteFlight(); the tick only
|
||||
// applies the pose and honours the same completion path as before.
|
||||
@@ -1252,6 +1467,186 @@ function onTick() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One frame of tilt. With a ground pivot the camera swings about it (lookAt),
|
||||
* so the place under the crosshair stays centred; without one it looks up or
|
||||
* down in place. Every angle and limit is the camera's own pitch, which at
|
||||
* regional range is several degrees steeper than the pivot's (the Earth
|
||||
* curves away), so the swing solves for the pivot pitch that gives it. Pitch
|
||||
* is clamped to [-89°, -5°] only in the direction of travel: a top-down view
|
||||
* sits at -90°, below the limit, and must still be able to tilt up out of it.
|
||||
* A pivot swing toward the horizon lowers the camera, so it stops at
|
||||
* TILT_MIN_CLEARANCE_M and the rest tilts in place. Farther out it stops with
|
||||
* the camera TILT_MIN_PIVOT_ELEVATION above the place's horizon instead, and
|
||||
* keeps the place centred: tilting in place there would lift the crosshair
|
||||
* onto the sky.
|
||||
*/
|
||||
function tickTilt(cam, m, scale, dt) {
|
||||
const from = cam.pitch;
|
||||
const want =
|
||||
m.mode === 'once'
|
||||
? m.startPitch + (m.goalPitch - m.startPitch) * scale
|
||||
: from +
|
||||
Cesium.Math.toRadians(TILT_DEG_S[m.speed]) *
|
||||
dt *
|
||||
(m.direction === 'up' ? 1 : -1);
|
||||
const up = want > from;
|
||||
const limited = up
|
||||
? Math.min(want, Math.max(from, PITCH_MAX))
|
||||
: Math.max(want, Math.min(from, PITCH_MIN));
|
||||
let atLimit = false;
|
||||
if (m.hpr) {
|
||||
// Apply a range a zoom set since the last frame, even when the pitch
|
||||
// then has nowhere to go (a zoom in to the clearance ends the pivot).
|
||||
cam.lookAt(m.target, m.hpr);
|
||||
// Up toward the horizon the swing lowers the camera: hold the clearance,
|
||||
// and keep the place below the horizon.
|
||||
const cap = up
|
||||
? Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range))
|
||||
: Infinity;
|
||||
for (let i = 0; i < 3 && Math.abs(limited - cam.pitch) > 1e-6; i += 1) {
|
||||
const pitch = Math.min(
|
||||
cap,
|
||||
Math.max(-Cesium.Math.PI_OVER_TWO, m.hpr.pitch + limited - cam.pitch),
|
||||
);
|
||||
if (pitch === m.hpr.pitch) break;
|
||||
m.hpr.pitch = pitch;
|
||||
cam.lookAt(m.target, m.hpr);
|
||||
}
|
||||
// A once motion that just completed released the lookAt frame before
|
||||
// this last step re-took it.
|
||||
if (_active !== m) clearLookAt();
|
||||
if (up && m.hpr.pitch >= cap && cam.pitch < limited - 1e-6) {
|
||||
if (tiltClearanceBinds(m.hpr.range)) {
|
||||
// Down to its clearance: let go of the pivot and tilt the rest in
|
||||
// place, which keeps the height.
|
||||
if (_active === m) clearLookAt();
|
||||
m.hpr = null;
|
||||
m.target = null;
|
||||
} else {
|
||||
// The place is at its horizon limit: stop here, still centred.
|
||||
atLimit = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!m.hpr) tiltInPlaceWithinFrame(cam, m, limited);
|
||||
if (m.mode !== 'once' && (limited !== want || atLimit))
|
||||
interruptCameraMotion('tilt-clamp');
|
||||
}
|
||||
|
||||
/** The refusal for a tilt up the camera has no room left for. */
|
||||
function tiltUpLimitRefusal() {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error:
|
||||
'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Shallowest pivot pitch that keeps the clearance at this range. */
|
||||
function tiltPivotPitchMax(range) {
|
||||
return Math.min(
|
||||
-Math.asin(Math.min(1, TILT_MIN_CLEARANCE_M / range)),
|
||||
-TILT_MIN_PIVOT_ELEVATION,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the clearance, not the horizon, stops a swing at this range. Only
|
||||
* then does the rest of the tilt happen in place: that close in (under ~17
|
||||
* km) the camera's -5° limit stays well below its own horizon.
|
||||
*/
|
||||
function tiltClearanceBinds(range) {
|
||||
return TILT_MIN_CLEARANCE_M / range >= Math.sin(TILT_MIN_PIVOT_ELEVATION);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shallowest camera pitch a tilt starting from this frame can reach with
|
||||
* the place under the crosshair still in view: PITCH_MAX, or less where a
|
||||
* far pivot meets its horizon limit first.
|
||||
*/
|
||||
function tiltUpLimit({ target, pivot }) {
|
||||
if (!pivot || tiltClearanceBinds(pivot.range)) return PITCH_MAX;
|
||||
return Math.min(
|
||||
PITCH_MAX,
|
||||
pivotCameraPitch(
|
||||
target,
|
||||
pivot,
|
||||
Math.max(pivot.pitch, tiltPivotPitchMax(pivot.range)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** The camera pitch lookAt gives for this pivot at the given pivot pitch. */
|
||||
function pivotCameraPitch(target, { heading, range }, pitch) {
|
||||
const offset = new Cesium.Cartesian3(
|
||||
-range * Math.cos(pitch) * Math.sin(heading),
|
||||
-range * Math.cos(pitch) * Math.cos(heading),
|
||||
-range * Math.sin(pitch),
|
||||
);
|
||||
const eye = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
offset,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const look = Cesium.Cartesian3.subtract(target, eye, new Cesium.Cartesian3());
|
||||
Cesium.Cartesian3.normalize(look, look);
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
const upAtEye = ellipsoid.geodeticSurfaceNormal(eye, new Cesium.Cartesian3());
|
||||
return Math.asin(
|
||||
Cesium.Math.clamp(Cesium.Cartesian3.dot(look, upAtEye), -1, 1),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a tilt starts. It swings about the ground under the crosshair so the
|
||||
* place stays centred. It tilts in place with no ground there, from globe
|
||||
* range, or while an entity is tracked (the follow camera owns that frame;
|
||||
* voice releases it before moving, so a voice tilt still pivots).
|
||||
*/
|
||||
function tiltStartFrame(cam) {
|
||||
const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer);
|
||||
let pivot = target ? tiltPivot(cam, target) : null;
|
||||
if (pivot && !tiltPivotInReach(pivot.range)) pivot = null;
|
||||
return { target, pivot, from: cam.pitch };
|
||||
}
|
||||
|
||||
/** Whether a tilt may pivot at this range: up to and including the cutoff. */
|
||||
function tiltPivotInReach(range) {
|
||||
return range <= TILT_PIVOT_MAX_RANGE_M + TILT_PIVOT_RANGE_SLACK_M;
|
||||
}
|
||||
|
||||
/** The reply's note for a tilt the place's horizon stops short of its angle. */
|
||||
function tiltHorizonNote(pitch) {
|
||||
return `Stopped at ${Math.round(Cesium.Math.toDegrees(pitch))}°: from this range a shallower angle puts the place past the horizon. Zoom in to tilt further.`;
|
||||
}
|
||||
|
||||
/** Ground pivot for a tilt: heading, pitch and range in the target's own frame. */
|
||||
function tiltPivot(cam, target) {
|
||||
const local = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Matrix4.inverseTransformation(
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
new Cesium.Matrix4(),
|
||||
),
|
||||
cam.positionWC,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const range = Cesium.Cartesian3.magnitude(local);
|
||||
if (!(range > 1) || local.z <= 0) return null;
|
||||
// Near straight overhead the offset's heading is float noise (it flipped a
|
||||
// north-up London view to south); keep the camera's own.
|
||||
const heading =
|
||||
Math.hypot(local.x, local.y) > range * 0.01
|
||||
? Math.atan2(-local.x, -local.y)
|
||||
: cam.heading;
|
||||
return new Cesium.HeadingPitchRange(
|
||||
heading,
|
||||
-Math.asin(Math.min(1, local.z / range)),
|
||||
range,
|
||||
);
|
||||
}
|
||||
|
||||
/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */
|
||||
function globeRotationHasClearance(position) {
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
@@ -1413,6 +1808,7 @@ export function initCameraVerbs(
|
||||
_isCockpitActive =
|
||||
typeof isCockpitActive === 'function' ? isCockpitActive : null;
|
||||
_viewer = viewer;
|
||||
_lookBaseline = null;
|
||||
if (_tickRemover) _tickRemover();
|
||||
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
|
||||
if (_trackingRemover) _trackingRemover();
|
||||
@@ -1421,18 +1817,108 @@ export function initCameraVerbs(
|
||||
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
|
||||
if (entity && _active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
// A pivoting tilt's lookAt would fight the follow camera every frame.
|
||||
else if (entity && _active?.kind === 'tilt' && _active.hpr)
|
||||
interruptCameraMotion('tilt-camera-owner-changed');
|
||||
});
|
||||
for (const rm of _inputRemovers) rm();
|
||||
_inputRemovers = [];
|
||||
const canvas = viewer.scene.canvas;
|
||||
// ANY manual camera input reclaims control — the cancelFlight reflex.
|
||||
for (const evt of ['pointerdown', 'touchstart', 'wheel']) {
|
||||
const h = () => interruptCameraMotion('manual-input');
|
||||
const h = () => {
|
||||
_manualInputCount += 1;
|
||||
interruptCameraMotion('manual-input');
|
||||
};
|
||||
canvas.addEventListener(evt, h, { passive: true });
|
||||
_inputRemovers.push(() => canvas.removeEventListener(evt, h));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tilt's angle: the one asked for, the default oblique view when a bare
|
||||
* "tilt the camera" names neither angle nor direction, or null for a nudge.
|
||||
*/
|
||||
function requestedTiltPitch(args, direction) {
|
||||
// Only a real number or numeric text: Number('') and Number(null) are 0,
|
||||
// which would swing the camera to the horizon.
|
||||
const raw = args.pitchDeg;
|
||||
const requested =
|
||||
typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '')
|
||||
? Number(raw)
|
||||
: NaN;
|
||||
// The camera never looks above the horizon, so a positive angle can only
|
||||
// mean the same angle below it ("tilt 45 degrees" sent as 45).
|
||||
if (Number.isFinite(requested))
|
||||
return Cesium.Math.clamp(
|
||||
Cesium.Math.toRadians(-Math.abs(requested)),
|
||||
PITCH_MIN,
|
||||
PITCH_MAX,
|
||||
);
|
||||
return direction === null
|
||||
? Cesium.Math.toRadians(TILT_DEFAULT_PITCH_DEG)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Argument errors move_camera refuses whatever the camera is doing, so a
|
||||
* caller holding a motion back behind a flight can refuse it before waiting.
|
||||
* @param {Object} args
|
||||
* @returns {Object|null} The refusal, or null when the arguments are usable.
|
||||
*/
|
||||
export function moveCameraArgsError(args = {}) {
|
||||
const motion = String(args.motion || '').toLowerCase();
|
||||
const direction = args.direction
|
||||
? String(args.direction).toLowerCase()
|
||||
: null;
|
||||
if (motion === 'stop') return null;
|
||||
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion !== 'orbit' &&
|
||||
motion !== 'rotate_globe' &&
|
||||
(motion !== 'tilt' || requestedTiltPitch(args, direction) === null) &&
|
||||
!['left', 'right', 'up', 'down'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `${motion} needs a direction (${motion === 'tilt' ? 'up/down' : motion === 'rotate' ? 'left/right' : 'left/right/up/down'}).`,
|
||||
};
|
||||
}
|
||||
if (motion === 'tilt' && (direction === 'left' || direction === 'right')) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'tilt needs up or down, or pitchDeg alone for an angle.',
|
||||
};
|
||||
}
|
||||
if (motion === 'rotate' && !['left', 'right'].includes(direction)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'rotate needs left or right.',
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion === 'rotate_globe' &&
|
||||
direction !== null &&
|
||||
!['left', 'right'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'rotate_globe needs left or right.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* move_camera implementation. Returns the house result shape; rejections are
|
||||
* plain-English `ok:false` errors.
|
||||
@@ -1465,54 +1951,66 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
};
|
||||
return typeof runNavigation === 'function' ? runNavigation(stop) : stop();
|
||||
}
|
||||
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion !== 'orbit' &&
|
||||
motion !== 'rotate_globe' &&
|
||||
!['left', 'right', 'up', 'down'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `${motion} needs a direction (left/right${motion !== 'rotate' ? '/up/down' : ''}).`,
|
||||
};
|
||||
}
|
||||
if (motion === 'rotate' && !['left', 'right'].includes(direction)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'rotate needs left or right.',
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion === 'rotate_globe' &&
|
||||
direction !== null &&
|
||||
!['left', 'right'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'rotate_globe needs left or right.',
|
||||
};
|
||||
}
|
||||
if (motion === 'tilt') {
|
||||
// Honest limits: at the clamp a tilt is a no-op — say so instead of
|
||||
// reporting success and letting the model gaslight itself (field test).
|
||||
const pitch = _viewer.camera.pitch;
|
||||
if (direction === 'up' && pitch >= PITCH_MAX - Cesium.Math.toRadians(0.5)) {
|
||||
const argsError = moveCameraArgsError(args);
|
||||
if (argsError) return argsError;
|
||||
const tiltToPitch =
|
||||
motion === 'tilt' ? requestedTiltPitch(args, direction) : null;
|
||||
if (motion === 'tilt' && tiltToPitch !== null) {
|
||||
// Angles and limits are the camera's own pitch, whatever the swing pivots
|
||||
// about; releasing the follow camera does not change it.
|
||||
const from = _viewer.camera.pitch;
|
||||
// -89 or -90 means top-down, which a -90° view already is: the clamp
|
||||
// must not tilt it up 1°.
|
||||
const atTopDown = tiltToPitch <= PITCH_MIN && from <= PITCH_MIN;
|
||||
if (
|
||||
atTopDown ||
|
||||
Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)
|
||||
) {
|
||||
// Still a camera request: it stops a running motion and releases the
|
||||
// follow camera, as a tilt that moves would.
|
||||
const hold = () => {
|
||||
interruptCameraMotion('replaced');
|
||||
return {
|
||||
ok: true,
|
||||
action: 'move_camera',
|
||||
motion,
|
||||
pitchDeg: Math.round(
|
||||
Cesium.Math.toDegrees(atTopDown ? from : tiltToPitch),
|
||||
),
|
||||
unchanged: true,
|
||||
};
|
||||
};
|
||||
return typeof runNavigation === 'function' ? runNavigation(hold) : hold();
|
||||
}
|
||||
// pitchDeg is an absolute angle, not a step: "tilt down 10 degrees" sent
|
||||
// as {direction:'down', pitchDeg:10} would tilt up from -45°.
|
||||
const towards = tiltToPitch > from ? 'up' : 'down';
|
||||
if ((direction === 'up' || direction === 'down') && direction !== towards) {
|
||||
const target = Math.round(Cesium.Math.toDegrees(tiltToPitch));
|
||||
const now = Math.round(Cesium.Math.toDegrees(from));
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error:
|
||||
'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.',
|
||||
error: `pitchDeg is an absolute angle, not a step: ${target}° from ${now}° tilts ${towards}, not ${direction}. Send direction alone for a nudge, or pitchDeg alone for an angle.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
towards === 'up' &&
|
||||
from >=
|
||||
tiltUpLimit(tiltStartFrame(_viewer.camera)) - Cesium.Math.toRadians(0.5)
|
||||
)
|
||||
return tiltUpLimitRefusal();
|
||||
} else if (motion === 'tilt') {
|
||||
// Honest limits: at the clamp a tilt is a no-op — say so instead of
|
||||
// reporting success and letting the model gaslight itself (field test).
|
||||
const pitch = _viewer.camera.pitch;
|
||||
if (
|
||||
direction === 'up' &&
|
||||
pitch >=
|
||||
tiltUpLimit(tiltStartFrame(_viewer.camera)) - Cesium.Math.toRadians(0.5)
|
||||
) {
|
||||
return tiltUpLimitRefusal();
|
||||
}
|
||||
if (
|
||||
direction === 'down' &&
|
||||
pitch <= PITCH_MIN + Cesium.Math.toRadians(0.5)
|
||||
@@ -1524,6 +2022,34 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
};
|
||||
}
|
||||
}
|
||||
// A tilt is judged by the way its pitch will move: toward its angle, or a
|
||||
// nudge up or down; a rotate turns left only when told to, as it runs.
|
||||
const look =
|
||||
motion === 'tilt'
|
||||
? tiltLook(
|
||||
_viewer.camera.pitch,
|
||||
tiltToPitch ??
|
||||
_viewer.camera.pitch +
|
||||
Cesium.Math.toRadians(ONCE.tiltDeg) *
|
||||
(direction === 'up' ? 1 : -1),
|
||||
)
|
||||
: motion === 'rotate'
|
||||
? direction === 'left'
|
||||
? 'lookLeft'
|
||||
: 'lookRight'
|
||||
: null;
|
||||
// Honest limits from orbit too: a stopped turn rests within 0.05° of its
|
||||
// limit, so one that cannot go half a degree further is refused rather than
|
||||
// run and reported as done, and one that can is then turned at least that
|
||||
// far. Near the limit the sampled share wobbles as the planet's edge crosses
|
||||
// the grid, so a turn that would keep the planet can occasionally be refused
|
||||
// too.
|
||||
// A camera that is tracked or locked now is judged as the navigation that
|
||||
// runs the turn will release it, so a refusal keeps the tracking.
|
||||
const lookRefusal =
|
||||
look &&
|
||||
checkLookFromOrbit(look, { released: typeof runNavigation === 'function' });
|
||||
if (lookRefusal) return lookRefusal;
|
||||
if (
|
||||
(motion === 'rotate_globe' ||
|
||||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
|
||||
@@ -1607,14 +2133,70 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
|
||||
};
|
||||
}
|
||||
// The angle a tilt to an angle stops at short of it, and why.
|
||||
let stoppedAt = null;
|
||||
let stopNote = null;
|
||||
const start = () => {
|
||||
interruptCameraMotion('replaced');
|
||||
// Checked again only if the release left the camera somewhere other than
|
||||
// where the check above expected it.
|
||||
if (look && !lookProven(_viewer.camera, look)) {
|
||||
const refusal = checkLookFromOrbit(look);
|
||||
if (refusal) return refusal;
|
||||
}
|
||||
const state = {
|
||||
kind: motion,
|
||||
direction: direction || 'right',
|
||||
speed,
|
||||
mode,
|
||||
};
|
||||
if (motion === 'tilt') {
|
||||
const frame = tiltStartFrame(_viewer.camera);
|
||||
const { target, pivot, from } = frame;
|
||||
if (pivot) {
|
||||
state.target = target;
|
||||
state.hpr = pivot;
|
||||
}
|
||||
state.startPitch = from;
|
||||
const upLimit = tiltUpLimit(frame);
|
||||
if (tiltToPitch !== null) {
|
||||
// From far out the place reaches its horizon before the camera
|
||||
// reaches the asked angle: stop there, and say so.
|
||||
let goal =
|
||||
tiltToPitch > from
|
||||
? Math.min(tiltToPitch, Math.max(from, upLimit))
|
||||
: tiltToPitch;
|
||||
if (goal !== tiltToPitch) stopNote = tiltHorizonNote(goal);
|
||||
// From orbit an in-place tilt stops where the planet would leave the
|
||||
// frame: aim it there, and say so.
|
||||
if (!pivot) {
|
||||
const framed = tiltPitchWithinFrame(_viewer.camera, goal);
|
||||
if (framed !== goal) {
|
||||
goal = framed;
|
||||
stopNote = tiltFrameNote(goal);
|
||||
}
|
||||
}
|
||||
state.mode = 'once';
|
||||
state.direction = goal > from ? 'up' : 'down';
|
||||
state.goalPitch = goal;
|
||||
// A zoom mid-tilt moves the horizon limit; it re-judges the goal
|
||||
// against the angle asked for.
|
||||
state.askedPitch = tiltToPitch;
|
||||
state.durationS = Math.max(
|
||||
ONCE.durationS,
|
||||
Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S,
|
||||
);
|
||||
if (goal !== tiltToPitch) stoppedAt = goal;
|
||||
} else {
|
||||
// A nudge stops at the limit it travels toward.
|
||||
const step =
|
||||
Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1);
|
||||
state.goalPitch =
|
||||
step > 0
|
||||
? Math.min(from + step, Math.max(from, upLimit))
|
||||
: Math.max(from + step, Math.min(from, PITCH_MIN));
|
||||
}
|
||||
}
|
||||
if (motion === 'rotate_globe') {
|
||||
// Convert an inherited ground-target frame without changing the world
|
||||
// pose. Rotation must pivot about Earth, never the previous local target.
|
||||
@@ -1633,9 +2215,11 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
}
|
||||
if (motion === 'orbit') {
|
||||
if (_viewer.scene.tweens.length > 0) {
|
||||
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm
|
||||
// and capture at the destination; a target existing right now is
|
||||
// meaningless (the globe is always under the crosshair).
|
||||
// A camera flight is in progress — ALWAYS arm and capture at the
|
||||
// destination; a target existing right now is meaningless (the globe
|
||||
// is always under the crosshair). The voice runner already holds an
|
||||
// orbit until its own fly-to lands; this covers any other
|
||||
// flight.
|
||||
setActiveMotion({
|
||||
kind: 'orbit',
|
||||
direction: direction || 'right',
|
||||
@@ -1699,7 +2283,11 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
motion,
|
||||
direction: state.direction,
|
||||
speed,
|
||||
mode,
|
||||
mode: state.mode,
|
||||
...(tiltToPitch !== null && {
|
||||
pitchDeg: Math.round(Cesium.Math.toDegrees(stoppedAt ?? tiltToPitch)),
|
||||
}),
|
||||
...(stopNote !== null && { note: stopNote }),
|
||||
};
|
||||
};
|
||||
const preserveCameraFlight =
|
||||
@@ -1714,16 +2302,93 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
/**
|
||||
* Zoom while orbiting: scale the orbit RADIUS instead of letting the zoom
|
||||
* fight the per-frame lookAt (field test: "zoom out did literally nothing").
|
||||
* A tilt swinging about its pivot holds a range the same way.
|
||||
* @param {number} factor >1 zooms out (radius grows), <1 zooms in.
|
||||
* @returns {boolean} true when an orbit consumed the zoom.
|
||||
* @returns {{kind: string, changed: boolean, pitchDeg?: number,
|
||||
* note?: string}|null} the motion that consumed the zoom, and whether its
|
||||
* range moved; null when none did. A tilt to an angle whose end the new
|
||||
* range moves also names the angle it now ends at (pitchDeg), with a note.
|
||||
*/
|
||||
export function adjustOrbitRange(factor) {
|
||||
if (!_active || _active.kind !== 'orbit' || !_active.hpr) return false;
|
||||
_active.hpr.range = Math.max(
|
||||
80,
|
||||
Math.min(5_000_000, _active.hpr.range * factor),
|
||||
if (!_active || !['orbit', 'tilt'].includes(_active.kind) || !_active.hpr)
|
||||
return null;
|
||||
// A tilt's zoom in stops at the range that keeps its clearance. The limits
|
||||
// never move a view the wrong way: a zoom in leaves a closer view in place,
|
||||
// and a zoom out leaves a farther one.
|
||||
const { kind, hpr } = _active;
|
||||
const floor =
|
||||
kind === 'tilt'
|
||||
? Math.max(80, TILT_MIN_CLEARANCE_M / Math.abs(Math.sin(hpr.pitch)))
|
||||
: 80;
|
||||
// A tilt pivots only within TILT_PIVOT_MAX_RANGE_M; farther out the swing
|
||||
// would carry the camera thousands of km.
|
||||
const ceiling = kind === 'tilt' ? TILT_PIVOT_MAX_RANGE_M : 5_000_000;
|
||||
const range = hpr.range;
|
||||
hpr.range = Math.max(
|
||||
Math.min(floor, range),
|
||||
Math.min(Math.max(ceiling, range), range * factor),
|
||||
);
|
||||
return true;
|
||||
// Under half a metre counts as not moved, as for a plain zoom.
|
||||
const changed = Math.abs(hpr.range - range) >= 0.5;
|
||||
return { kind, changed, ...(changed && retargetTiltAfterZoom(_active)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A zoom changed the range a tilt to an angle holds, and with it the place's
|
||||
* horizon limit: aim the tilt at the angle asked for or the new limit,
|
||||
* whichever comes first, from where the camera is now. The pivot pitch is
|
||||
* re-solved so the new range keeps the camera's pitch, and a limit the camera
|
||||
* is already past is eased down to rather than jumped to. Returns the end
|
||||
* angle and a note for the reply when that end moved, otherwise nothing.
|
||||
*/
|
||||
function retargetTiltAfterZoom(m) {
|
||||
if (m.kind !== 'tilt' || m.mode !== 'once' || m.askedPitch === undefined)
|
||||
return {};
|
||||
let from = _viewer.camera.pitch;
|
||||
if (!(m.askedPitch > from)) return {};
|
||||
if (m.hpr) {
|
||||
// At the same pivot pitch a longer range looks down more steeply (the
|
||||
// Earth curves away): find the pivot pitch that holds this camera pitch.
|
||||
// It stays below the place's horizon limit (or where the pivot already
|
||||
// was): past it the place drops behind the Earth, so a camera the zoom
|
||||
// leaves past that limit steps down to it with the zoom.
|
||||
const cap = Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range));
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const error = from - pivotCameraPitch(m.target, m.hpr, m.hpr.pitch);
|
||||
if (Math.abs(error) < 1e-7) break;
|
||||
const pitch = Cesium.Math.clamp(
|
||||
m.hpr.pitch + error,
|
||||
-Cesium.Math.PI_OVER_TWO,
|
||||
cap,
|
||||
);
|
||||
if (pitch === m.hpr.pitch) break;
|
||||
m.hpr.pitch = pitch;
|
||||
}
|
||||
from = Math.min(from, pivotCameraPitch(m.target, m.hpr, m.hpr.pitch));
|
||||
}
|
||||
const limit =
|
||||
m.hpr && !tiltClearanceBinds(m.hpr.range)
|
||||
? Math.min(
|
||||
PITCH_MAX,
|
||||
pivotCameraPitch(m.target, m.hpr, tiltPivotPitchMax(m.hpr.range)),
|
||||
)
|
||||
: PITCH_MAX;
|
||||
const goal = Math.min(m.askedPitch, limit);
|
||||
if (Math.abs(goal - m.goalPitch) < 1e-6) return {};
|
||||
m.goalPitch = goal;
|
||||
m.startPitch = from;
|
||||
m.elapsed = 0;
|
||||
m.durationS = Math.max(
|
||||
ONCE.durationS,
|
||||
Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S,
|
||||
);
|
||||
const limited = goal !== m.askedPitch;
|
||||
return {
|
||||
pitchDeg: Math.round(Cesium.Math.toDegrees(goal)),
|
||||
note: limited
|
||||
? tiltHorizonNote(goal)
|
||||
: `The tilt now reaches ${Math.round(Cesium.Math.toDegrees(goal))}°.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1306,12 +1306,12 @@ test('voice Cockpit entry honours a requested contact layer before it enters', (
|
||||
ui.indexOf("if (normalized === 'exit') {"),
|
||||
);
|
||||
assert.ok(branch, 'cockpit enter branch is missing');
|
||||
const gateIndex = branch.indexOf('isEntryAllowed?.()');
|
||||
const gateIndex = branch.indexOf('this._cockpitEntryRefusal()');
|
||||
const retargetIndex = branch.indexOf('_retargetCockpitEntryLayer(');
|
||||
const enterIndex = branch.indexOf('enterCockpitWithTracking({');
|
||||
assert.ok(
|
||||
gateIndex >= 0,
|
||||
'entry consults the same gate the manual chip uses',
|
||||
'entry refuses on the gate it can check before tracking',
|
||||
);
|
||||
assert.ok(
|
||||
retargetIndex > gateIndex,
|
||||
@@ -1362,9 +1362,21 @@ test('voice Cockpit entry refuses when the entry gate is shut', () => {
|
||||
ui.indexOf("if (normalized === 'enter') {"),
|
||||
ui.indexOf("if (normalized === 'exit') {"),
|
||||
);
|
||||
assert.match(branch, /if \(!this\.cockpitView\.isEntryAllowed\?\.\(\)\) \{/);
|
||||
assert.match(branch, /Contacts must be active to enter Cockpit/);
|
||||
assert.match(branch, /Contacts is still starting up/);
|
||||
// Contacts is not required: only a Context change in flight or no aircraft
|
||||
// layer to ride shuts the gate before tracking, and the entry itself then
|
||||
// applies the manual chip's gate to the aircraft it rides.
|
||||
assert.match(
|
||||
branch,
|
||||
/const refusal = this\._cockpitEntryRefusal\(\);\s*if \(refusal\) \{/,
|
||||
);
|
||||
assert.doesNotMatch(branch, /Contacts must be active/);
|
||||
const refusal = ui.slice(
|
||||
ui.indexOf(' _cockpitEntryRefusal() {'),
|
||||
ui.indexOf(' getCockpitState() {'),
|
||||
);
|
||||
assert.match(refusal, /Context is still changing/);
|
||||
assert.match(refusal, /No aircraft layer is on/);
|
||||
assert.doesNotMatch(refusal, /[Cc]ontacts/);
|
||||
});
|
||||
|
||||
test('cockpit state cannot report entryAllowed while already active', () => {
|
||||
@@ -1382,7 +1394,8 @@ test('cockpit state cannot report entryAllowed while already active', () => {
|
||||
);
|
||||
assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained');
|
||||
assert.match(state, /'contacts-starting'/);
|
||||
assert.match(state, /'contacts-inactive'/);
|
||||
assert.match(state, /'subject-layer-off'/);
|
||||
assert.doesNotMatch(state, /'contacts-inactive'/, 'Contacts is not required');
|
||||
assert.match(state, /'no-tracked-aircraft'/);
|
||||
});
|
||||
|
||||
|
||||
@@ -147,6 +147,40 @@ const snapshot = (distanceM = 2000) => ({
|
||||
],
|
||||
});
|
||||
|
||||
test('a feed-off row leads the rebuilt rows and the list stays five long', async (t) => {
|
||||
const f = fixture(t);
|
||||
const crowded = {
|
||||
...snapshot(),
|
||||
cohorts: [
|
||||
{
|
||||
id: 'flights',
|
||||
count: 6,
|
||||
nearest: [1, 2, 3, 4, 5, 6].map((n) => ({
|
||||
id: `n${n}`,
|
||||
label: `N${n}`,
|
||||
distanceM: n * 1000,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
updateCockpitSignals.call(f.owner, crowded, 0, {
|
||||
title: 'MILITARY FEED OFF · PARTIAL PICTURE',
|
||||
detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(f.owner.signalItems.length, 5);
|
||||
assert.equal(f.owner.signalItems[0].key, 'feed-status');
|
||||
assert.equal(f.owner.signalItems[0].tone, 'warning');
|
||||
assert.equal(f.owner.signalItems[0].target, null);
|
||||
assert.match(f.list.textContent, /MILITARY FEED OFF · PARTIAL PICTURE/);
|
||||
updateCockpitSignals.call(f.owner, crowded, 0);
|
||||
assert.equal(
|
||||
f.owner.signalItems.some((item) => item.key === 'feed-status'),
|
||||
false,
|
||||
'with the feed back, the row goes',
|
||||
);
|
||||
});
|
||||
|
||||
test('rows render the signals the controller presents', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(f, [
|
||||
|
||||
+23
-16
@@ -411,30 +411,37 @@ export function contextRestoreLayerIds(snapshot = {}) {
|
||||
return restored;
|
||||
}
|
||||
|
||||
/** The layers an aircraft Cockpit subject can come from. */
|
||||
export const COCKPIT_AIRCRAFT_LAYER_IDS = Object.freeze([
|
||||
'flights',
|
||||
'military',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Cockpit entry belongs to the operational Contacts context bundle. A tracked
|
||||
* aircraft alone is not sufficient: both observed-flight feeds must still be
|
||||
* active so the cockpit's surrounding-contact picture is not presented as a
|
||||
* complete context view when one source is disabled.
|
||||
* Cockpit entry needs only the aircraft it rides: that aircraft's own layer
|
||||
* (Live Flights for a civilian, Military Flights for a military aircraft) must
|
||||
* be on. Contacts and the other feeds are not required. When a source the
|
||||
* surrounding picture uses is off, the Cockpit's Contact card and Live
|
||||
* Signals say the picture is partial instead of blocking entry, so a partial
|
||||
* picture is never presented as complete. A Context mode change in flight
|
||||
* still owns the camera, so entry waits it out.
|
||||
*
|
||||
* @param {object} input Current context and dependency visibility.
|
||||
* @param {string|null} input.contextMode Active Context mode.
|
||||
* @param {boolean} input.contextModeChanging Whether the Context transaction is unsettled.
|
||||
* @param {boolean} input.flightsEnabled Whether Live Flights is enabled.
|
||||
* @param {boolean} input.militaryEnabled Whether Military Flights is enabled.
|
||||
* @param {object} input Current subject and Context state.
|
||||
* @param {string|null} input.subjectLayerId Layer of the tracked aircraft
|
||||
* ('flights' or 'military'), or null with none tracked.
|
||||
* @param {boolean} input.subjectLayerEnabled Whether that layer is enabled.
|
||||
* @param {boolean} input.contextModeChanging Whether a Context transaction is unsettled.
|
||||
* @returns {boolean} Whether cockpit entry may be offered or activated.
|
||||
*/
|
||||
export function cockpitEntryAllowed({
|
||||
contextMode,
|
||||
subjectLayerId,
|
||||
subjectLayerEnabled,
|
||||
contextModeChanging,
|
||||
flightsEnabled,
|
||||
militaryEnabled,
|
||||
}) {
|
||||
return (
|
||||
contextMode === 'flights' &&
|
||||
!contextModeChanging &&
|
||||
Boolean(flightsEnabled) &&
|
||||
Boolean(militaryEnabled)
|
||||
COCKPIT_AIRCRAFT_LAYER_IDS.includes(subjectLayerId) &&
|
||||
Boolean(subjectLayerEnabled) &&
|
||||
!contextModeChanging
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -400,37 +400,32 @@ test('explicit layer intent finishing during restore overrides the stale queued
|
||||
}
|
||||
});
|
||||
|
||||
test('cockpit entry requires Contacts context with both aircraft feeds enabled', () => {
|
||||
test("cockpit entry needs only the tracked aircraft's own layer", () => {
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
subjectLayerId: 'flights',
|
||||
subjectLayerEnabled: true,
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: true,
|
||||
}), true);
|
||||
}), true, 'a civilian needs Live Flights alone: no Contacts, no Military');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
subjectLayerId: 'military',
|
||||
subjectLayerEnabled: true,
|
||||
contextModeChanging: false,
|
||||
}), true, 'a military aircraft needs Military Flights alone');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
subjectLayerId: 'flights',
|
||||
subjectLayerEnabled: true,
|
||||
contextModeChanging: true,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: true,
|
||||
}), false, 'Cockpit remains unavailable until Contacts activation settles');
|
||||
}), false, 'Cockpit remains unavailable until a Context mode change settles');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: null,
|
||||
subjectLayerId: 'military',
|
||||
subjectLayerEnabled: false,
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: true,
|
||||
}), false, 'ordinary layer selection is not an operational Contacts context');
|
||||
}), false, "the ridden aircraft's own layer is required");
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
subjectLayerId: null,
|
||||
subjectLayerEnabled: false,
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: false,
|
||||
}), false, 'the military contact feed is required');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: false,
|
||||
militaryEnabled: true,
|
||||
}), false, 'the civilian contact feed is required');
|
||||
}), false, 'there is no aircraft to ride');
|
||||
});
|
||||
|
||||
test('context teardown guard restores coordination state after success and failure', async () => {
|
||||
|
||||
@@ -67,6 +67,8 @@ export const _extractPickedCameraIdForTest =
|
||||
export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest;
|
||||
export const focusCctvRecord = layer.focusCctvRecord;
|
||||
export const maybeAutoHop = layer.maybeAutoHop;
|
||||
export const openingCameraId = layer.openingCameraId;
|
||||
export const reopenNearestCamera = layer.reopenNearestCamera;
|
||||
export const cctvCycleIndex = layer.cctvCycleIndex;
|
||||
export {
|
||||
CCTV_CALIBRATION_STORAGE_KEY_V1,
|
||||
|
||||
@@ -61,6 +61,8 @@ import cctvLayer, {
|
||||
materializeCctvActiveCoverageEntities,
|
||||
materializeCctvVisibleCoverageEntities,
|
||||
maybeAutoHop,
|
||||
openingCameraId,
|
||||
reopenNearestCamera,
|
||||
prioritizeActiveCctvGeometryRecord,
|
||||
processCctvGeometryDrainBatch,
|
||||
processCctvGeometryQueueBatch,
|
||||
@@ -1296,6 +1298,120 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV opens on the camera nearest the viewer, not the first in the catalog', () => {
|
||||
// QA 2026-11-05: at King's Cross, turning CCTV on showed a Sydney camera,
|
||||
// the first catalog record, instead of the TfL camera 0.3 km away.
|
||||
const viewer = makeDeselectViewer();
|
||||
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
|
||||
-0.1209,
|
||||
51.5295,
|
||||
187,
|
||||
);
|
||||
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
-0.1209,
|
||||
51.5295,
|
||||
187,
|
||||
);
|
||||
const sydney = makeDeselectRecord('au-sydney');
|
||||
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
|
||||
const london = makeDeselectRecord('gb-kings-cross');
|
||||
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
|
||||
try {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer,
|
||||
records: [sydney, london],
|
||||
activeCameraId: null,
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(openingCameraId(), 'gb-kings-cross');
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: null,
|
||||
records: [sydney, london],
|
||||
activeCameraId: null,
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(
|
||||
openingCameraId(),
|
||||
'au-sydney',
|
||||
'with no viewer position the first record is the fallback',
|
||||
);
|
||||
_setCctvCoverageStateForTest({ viewer, records: [], enabled: false });
|
||||
assert.equal(openingCameraId(), null);
|
||||
} finally {
|
||||
_setCctvCoverageStateForTest({ enabled: false });
|
||||
}
|
||||
});
|
||||
|
||||
test('the opening camera follows a shared view that lands after CCTV turned on, until someone picks one', () => {
|
||||
// A share link turns CCTV on before its flight lands, so enable() picks
|
||||
// from the startup view; the flight's settled move re-picks near London.
|
||||
const viewer = makeDeselectViewer();
|
||||
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
|
||||
-0.1209,
|
||||
51.5295,
|
||||
187,
|
||||
);
|
||||
const sydney = makeDeselectRecord('au-sydney');
|
||||
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
|
||||
const london = makeDeselectRecord('gb-kings-cross');
|
||||
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
|
||||
for (const record of [sydney, london])
|
||||
Object.assign(record, {
|
||||
groundResolved: {},
|
||||
groundSamples: {},
|
||||
groundPrior: 0,
|
||||
frustumGeometry: null,
|
||||
frustumPositions: null,
|
||||
probeClampRangeM: null,
|
||||
});
|
||||
_setCctvOverlayHostForTest({
|
||||
setEntries() {},
|
||||
setVisible() {},
|
||||
clearSource() {},
|
||||
});
|
||||
// Activation builds the projection canvas and frame image.
|
||||
const originalDocument = globalThis.document;
|
||||
const originalImage = globalThis.Image;
|
||||
globalThis.document = {
|
||||
createElement: () => ({ getContext: () => null }),
|
||||
};
|
||||
globalThis.Image = class {};
|
||||
try {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer,
|
||||
records: [sydney, london],
|
||||
activeCameraId: 'au-sydney',
|
||||
enabled: true,
|
||||
openingCameraProvisional: true,
|
||||
});
|
||||
assert.equal(reopenNearestCamera(), true);
|
||||
assert.equal(cctvLayer.getUIState().activeCameraId, 'gb-kings-cross');
|
||||
assert.equal(reopenNearestCamera(), false, 'already the nearest');
|
||||
|
||||
// Someone chooses a camera: settled moves leave it alone.
|
||||
setActiveCamera('au-sydney');
|
||||
assert.equal(reopenNearestCamera(), false);
|
||||
assert.equal(cctvLayer.getUIState().activeCameraId, 'au-sydney');
|
||||
|
||||
// A deselect is a choice too.
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer,
|
||||
records: [sydney, london],
|
||||
activeCameraId: 'au-sydney',
|
||||
enabled: true,
|
||||
openingCameraProvisional: true,
|
||||
});
|
||||
deactivateActiveCamera();
|
||||
assert.equal(reopenNearestCamera(), false);
|
||||
assert.equal(cctvLayer.getUIState().activeCameraId, null);
|
||||
} finally {
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.Image = originalImage;
|
||||
_setCctvOverlayHostForTest();
|
||||
_setCctvCoverageStateForTest({ enabled: false });
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV disable clears and hides its shared-host source through the real layer lifecycle', () => {
|
||||
const calls = [];
|
||||
_setCctvOverlayHostForTest({
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
CALGARY_IMAGE_ORIGIN,
|
||||
CALGARY_MAX_CATALOG_BYTES,
|
||||
DEFAULT_CALGARY_ROWS_URL,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
|
||||
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
|
||||
@@ -384,6 +384,6 @@ test('CCTV_CALGARY_ENABLED=0 keeps the lane from being loaded at all', async (t)
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||
});
|
||||
@@ -6,9 +6,12 @@ import {
|
||||
} from '../../server/providers/cctv/cap.js';
|
||||
import {
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { QUEBEC_CAMERA_SOURCE_COUNT } from '../../server/providers/cctv/quebec.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
const pack = (name, count, prefix = name) => ({
|
||||
@@ -99,6 +102,68 @@ test('duplicate ids resolve last-pack-wins and count against the winning pack',
|
||||
]);
|
||||
});
|
||||
|
||||
/** Shared packs plus two reserved ones, shaped like the default catalog. */
|
||||
const reservedFixture = () => [
|
||||
...Array.from({ length: 20 }, (_, i) => pack(`shared${i}`, 250)),
|
||||
{ ...pack('quebec', 1120), reservedCount: 1120 },
|
||||
{ ...pack('world', 2230), reservedCount: 2230 },
|
||||
];
|
||||
const keptByName = (packs) =>
|
||||
Object.fromEntries(packs.map(({ name, kept }) => [name, kept]));
|
||||
|
||||
test('the default cap is the shared floor plus the reserved Québec and World packs', () => {
|
||||
assert.equal(
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS +
|
||||
QUEBEC_CAMERA_SOURCE_COUNT +
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
);
|
||||
});
|
||||
|
||||
test('at the default cap reserved packs are whole and shared packs keep the floor', () => {
|
||||
const { sources, packs } = allocateSourceCap(
|
||||
reservedFixture(),
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
);
|
||||
const kept = keptByName(packs);
|
||||
assert.equal(sources.length, DEFAULT_CCTV_MAX_SOURCES);
|
||||
assert.equal(kept.quebec, 1120);
|
||||
assert.equal(kept.world, 2230);
|
||||
for (let i = 0; i < 20; i++) assert.equal(kept[`shared${i}`], 200);
|
||||
assert.match(
|
||||
sources[0].id,
|
||||
/^shared0-/,
|
||||
'the first camera stays in a shared pack',
|
||||
);
|
||||
});
|
||||
|
||||
test('a cap at the shared floor reserves nothing and thins every pack evenly', () => {
|
||||
const { packs } = allocateSourceCap(reservedFixture(), 4000);
|
||||
const kept = keptByName(packs);
|
||||
// 22 lanes share 4,000 round-robin: nobody is wiped out by the reservation.
|
||||
for (const count of Object.values(kept))
|
||||
assert.ok(count >= 181 && count <= 182);
|
||||
});
|
||||
|
||||
test('a small cap (dev-secure 48) still reaches the shared packs first', () => {
|
||||
const { sources, packs } = allocateSourceCap(reservedFixture(), 48);
|
||||
const kept = keptByName(packs);
|
||||
assert.equal(sources.length, 48);
|
||||
for (let i = 0; i < 20; i++) assert.ok(kept[`shared${i}`] >= 2);
|
||||
assert.match(sources[0].id, /^shared0-/);
|
||||
});
|
||||
|
||||
test('reservations fill only the room above the floor, round-robin between reserved packs', () => {
|
||||
const { packs } = allocateSourceCap(reservedFixture(), 4100);
|
||||
const kept = keptByName(packs);
|
||||
const shared = Object.entries(kept)
|
||||
.filter(([name]) => name.startsWith('shared'))
|
||||
.reduce((sum, [, count]) => sum + count, 0);
|
||||
// 100 reserved slots split between Québec and World, then 4,000 round-robin.
|
||||
assert.ok(shared >= 4000 - 2 * 182, `shared packs kept ${shared}`);
|
||||
assert.equal(shared + kept.quebec + kept.world, 4100);
|
||||
});
|
||||
|
||||
test('resolveCatalogCap bounds the env value and falls back to the default', () => {
|
||||
assert.equal(resolveCatalogCap(undefined), DEFAULT_CCTV_MAX_SOURCES);
|
||||
assert.equal(resolveCatalogCap('nonsense'), DEFAULT_CCTV_MAX_SOURCES);
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
DEFAULT_ONTARIO_MAX_SOURCES,
|
||||
DEFAULT_FINTRAFFIC_MAX_SOURCES,
|
||||
DEFAULT_TXDOT_MAX_SOURCES,
|
||||
DEFAULT_OTTAWA_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_TALLINN_MAX_SOURCES,
|
||||
DEFAULT_TARKTEE_MAX_SOURCES,
|
||||
DEFAULT_NSW_MAX_SOURCES,
|
||||
@@ -222,7 +224,7 @@ test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the r
|
||||
);
|
||||
});
|
||||
|
||||
test('default caps of the original packs fit inside the default catalog cap', () => {
|
||||
test('default caps of the original packs fit inside the shared catalog slots', () => {
|
||||
// Every default pack at its own cap (Warendorf ships 1 curated camera).
|
||||
const packs =
|
||||
DEFAULT_AUSTIN_MAX_SOURCES +
|
||||
@@ -231,15 +233,18 @@ test('default caps of the original packs fit inside the default catalog cap', ()
|
||||
DEFAULT_ONTARIO_MAX_SOURCES +
|
||||
DEFAULT_FINTRAFFIC_MAX_SOURCES +
|
||||
DEFAULT_DRIVEBC_MAX_SOURCES +
|
||||
DEFAULT_OTTAWA_MAX_SOURCES +
|
||||
DEFAULT_TXDOT_MAX_SOURCES +
|
||||
DEFAULT_TALLINN_MAX_SOURCES +
|
||||
DEFAULT_TARKTEE_MAX_SOURCES +
|
||||
DEFAULT_NSW_MAX_SOURCES +
|
||||
DEFAULT_CATALONIA_MAX_SOURCES +
|
||||
1;
|
||||
// The reserved Québec and World packs sit above the shared floor, so the
|
||||
// shared packs must fit the floor itself, not the whole default cap.
|
||||
assert.ok(
|
||||
packs <= DEFAULT_CCTV_MAX_SOURCES,
|
||||
`default packs (${packs}) would be thinned by the catalog cap (${DEFAULT_CCTV_MAX_SOURCES})`,
|
||||
packs <= CCTV_SHARED_SOURCE_SLOTS,
|
||||
`default packs (${packs}) would be thinned by the shared slots (${CCTV_SHARED_SOURCE_SLOTS})`,
|
||||
);
|
||||
assert.ok(
|
||||
DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
parseHkCameraLocationsXml,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_HK_CAMERAS_URL,
|
||||
DEFAULT_HK_MAX_SOURCES,
|
||||
HK_ANCHORS,
|
||||
@@ -396,8 +396,8 @@ test('CCTV_HK_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||
});
|
||||
|
||||
test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => {
|
||||
@@ -406,7 +406,7 @@ test('the HK pack default sits above the full TD catalog and anchors the New Ter
|
||||
'default cap should keep nearly all published TD cameras',
|
||||
);
|
||||
assert.ok(
|
||||
DEFAULT_HK_MAX_SOURCES <= DEFAULT_CCTV_MAX_SOURCES,
|
||||
DEFAULT_HK_MAX_SOURCES <= CCTV_SHARED_SOURCE_SLOTS,
|
||||
'a single pack must not outgrow the catalog-wide ceiling alone',
|
||||
);
|
||||
const places = HK_ANCHORS.map(
|
||||
|
||||
@@ -10,6 +10,77 @@ const playlist =
|
||||
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:12\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n';
|
||||
const base = 'https://camera.example/live/list.m3u8';
|
||||
|
||||
test('high-resolution HLS segments play while the retained session cache stays bounded', async (t) => {
|
||||
// Giroc publishes roughly ten-second TS segments; one measured 6,437,120
|
||||
// bytes. The former 4 MiB download cap rejected it before playback began.
|
||||
const segmentBytes = 6_437_120;
|
||||
const master =
|
||||
'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=4570000\ntracks-v1a1/mono.m3u8\n';
|
||||
const media =
|
||||
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:100\n' +
|
||||
[0, 1, 2, 3, 4].map((i) => `#EXTINF:10.272,\n${i}.ts\n`).join('');
|
||||
const manager = createHlsPuller({
|
||||
limits: { ...HLS_LIMITS, pollMs: 100000 },
|
||||
fetchImpl: async (url) => {
|
||||
if (url === base) return new Response(master);
|
||||
if (url === 'https://camera.example/live/tracks-v1a1/mono.m3u8')
|
||||
return new Response(media);
|
||||
const match =
|
||||
/^https:\/\/camera\.example\/live\/tracks-v1a1\/([0-4])\.ts$/.exec(url);
|
||||
if (!match) throw new Error('Unexpected playlist reference');
|
||||
// No Content-Length: the actual streamed bytes must also be accepted.
|
||||
return new Response(Buffer.alloc(segmentBytes, Number(match[1])));
|
||||
},
|
||||
});
|
||||
t.after(() => manager.shutdown());
|
||||
const entry = await manager.ensure('giroc', base);
|
||||
await entry.polling;
|
||||
const result = await manager.buildPlaylist(entry, 'giroc');
|
||||
assert.ok(result, 'large valid segments should produce a playable playlist');
|
||||
assert.match(result, /#EXT-X-MEDIA-SEQUENCE:2\n/);
|
||||
assert.equal(manager.getSegment('giroc', entry.token, 0), null);
|
||||
const newest = manager.getSegment('giroc', entry.token, 4);
|
||||
assert.equal(newest.length, segmentBytes);
|
||||
assert.equal(newest[0], 4);
|
||||
assert.deepEqual(manager.stats(), { sessions: 1, bytes: 19_311_360 });
|
||||
});
|
||||
|
||||
test('HLS downloads still reject declared and chunked bodies above 8 MiB', async () => {
|
||||
for (const declared of [true, false]) {
|
||||
let chunks = 0;
|
||||
let cancelled = false;
|
||||
await assert.rejects(
|
||||
fetchHlsBytes(base, {
|
||||
maxBytes: HLS_LIMITS.segmentBytes,
|
||||
fetchImpl: async () =>
|
||||
new Response(
|
||||
declared
|
||||
? 'small body'
|
||||
: new ReadableStream({
|
||||
pull(controller) {
|
||||
// Each chunk fits on its own; only their cumulative size
|
||||
// crosses the cap. Leave it open to verify cancellation.
|
||||
controller.enqueue(
|
||||
Buffer.alloc(4 * 1024 * 1024 + (++chunks === 2 ? 1 : 0)),
|
||||
);
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: declared
|
||||
? { 'content-length': String(8 * 1024 * 1024 + 1) }
|
||||
: {},
|
||||
},
|
||||
),
|
||||
}),
|
||||
/HLS response too large/,
|
||||
);
|
||||
if (!declared) assert.equal(cancelled, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('playlist parser uses sequence, refuses escaping and unsupported references', () => {
|
||||
assert.deepEqual(
|
||||
parseHlsMedia(playlist, base).map((s) => s.seq),
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
loadHoustonSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_HOUSTON_MAX_SOURCES,
|
||||
HOUSTON_CAMERAS_URL,
|
||||
HOUSTON_GROUND_ELEVATION_M,
|
||||
@@ -387,8 +387,8 @@ test('Houston stays unloaded unless CCTV_HOUSTON_ENABLED=1', async (t) => {
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||
assert.equal(DEFAULT_HOUSTON_MAX_SOURCES, 250);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import {
|
||||
DEFAULT_ICELAND_MAX_SOURCES,
|
||||
ICELAND_CAMERAS_URL,
|
||||
ICELAND_IMAGE_ORIGIN,
|
||||
ICELAND_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
|
||||
import { isLikelyIcelandCoordinate } from '../../server/providers/cctv/normalize.js';
|
||||
import {
|
||||
icelandCameraToSource,
|
||||
icelandHeadingFromDescription,
|
||||
loadIcelandSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
|
||||
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
||||
function withEnv(t, env) {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** One row shaped like the live `vefmyndavelar2014_1` payload. */
|
||||
const row = (over = {}) => ({
|
||||
Maelist_nr: 7001,
|
||||
Myndavel: 'Hellisheiði',
|
||||
Vegheiti: 'Hringvegur',
|
||||
NrVegur: '1',
|
||||
Skyring: 'Hellisheiði séð til vesturs',
|
||||
Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`,
|
||||
PntX: 395000,
|
||||
PntY: 390000,
|
||||
Breidd: 64.0136,
|
||||
Lengd: -21.4,
|
||||
...over,
|
||||
});
|
||||
|
||||
/** A live response body plus a flag that flips when the stream is cancelled. */
|
||||
const streamingResponse = (init = {}) => {
|
||||
const state = { cancelled: false };
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('['));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
const json = (value) =>
|
||||
new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
test('an Iceland row maps to a source on the pinned frame directory', () => {
|
||||
const source = icelandCameraToSource(row());
|
||||
assert.equal(source.id, 'is-vegagerdin-hellisheidi_1');
|
||||
assert.equal(source.name, 'Hellisheiði séð til vesturs');
|
||||
assert.equal(source.city, 'Iceland');
|
||||
assert.equal(source.cityId, 'iceland');
|
||||
assert.equal(source.provider, 'Vegagerðin (IRCA)');
|
||||
assert.equal(source.lat, 64.0136);
|
||||
assert.equal(source.lon, -21.4);
|
||||
assert.equal(source.headingDeg, 270);
|
||||
assert.equal(source.headingConfidence, 'high');
|
||||
assert.equal(source.pitchDeg, -24);
|
||||
assert.equal(source.fovDeg, 56);
|
||||
assert.equal(source.rangeM, 210);
|
||||
assert.equal(source.mountHeightM, 10);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'iceland-vegagerdin');
|
||||
assert.equal(source.code, 'HELLISHEIÐI');
|
||||
assert.match(source.license, /CC BY 4\.0/);
|
||||
assert.ok(source.url.startsWith(ICELAND_IMAGE_ORIGIN));
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('a view that states no facing takes the low-confidence id-hash pose', () => {
|
||||
const source = icelandCameraToSource(
|
||||
row({
|
||||
Skyring: 'Hellisheiði séð niður á veg',
|
||||
Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg`,
|
||||
}),
|
||||
);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.headingDeg % 22.5, 0);
|
||||
assert.equal(
|
||||
source.headingDeg,
|
||||
icelandCameraToSource(
|
||||
row({ Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg` }),
|
||||
).headingDeg,
|
||||
'the fallback depends on the id, not on the text',
|
||||
);
|
||||
});
|
||||
|
||||
test('the label collapses the feed’s doubled spaces and repeated words', () => {
|
||||
assert.equal(
|
||||
icelandCameraToSource(
|
||||
row({ Skyring: 'Strandarheiði séð niður á veg til til austurs' }),
|
||||
).name,
|
||||
'Strandarheiði séð niður á veg til austurs',
|
||||
);
|
||||
const label = (Skyring) => icelandCameraToSource(row({ Skyring })).name;
|
||||
// Repeats of words with Icelandic letters collapse too.
|
||||
assert.equal(
|
||||
label('Hellisheiði séð séð til austurs'),
|
||||
'Hellisheiði séð til austurs',
|
||||
);
|
||||
assert.equal(label('Brú á á Jökulsá'), 'Brú á Jökulsá');
|
||||
// A word ending in a letter the next word starts with is not a repeat.
|
||||
for (const text of [
|
||||
'Þverá Árnessýslu',
|
||||
'brú Úlfarsá',
|
||||
'Vesturlandsvegur við Elliðaá Ártúnsbrekku',
|
||||
])
|
||||
assert.equal(label(text), text);
|
||||
});
|
||||
|
||||
test('a row with no label, station or station number is named by its frame', () => {
|
||||
const source = icelandCameraToSource(
|
||||
row({ Skyring: '', Myndavel: '', Maelist_nr: undefined }),
|
||||
);
|
||||
assert.equal(source.name, 'Vegagerðin hellisheidi_1');
|
||||
});
|
||||
|
||||
test('only an explicit facing sets a heading', () => {
|
||||
const cases = [
|
||||
['Hellisheiði séð til vesturs', 270],
|
||||
['Holtavörðuheiði til norðurs', 0],
|
||||
['Klettsháls í suður', 180],
|
||||
['Arnarneshæð séð austur', 90],
|
||||
['Bústaðabrú séð norður Kringlumýrarbraut', 0],
|
||||
['Staður séð til norðausturs', 45],
|
||||
['Staður séð til suðausturs', 135],
|
||||
['Staður séð í suðvestur', 225],
|
||||
['Staður séð til norðvesturs', 315],
|
||||
['STAÐUR SÉÐ TIL VESTURS', 270],
|
||||
['Brú séð til austurs og Selfoss', 90], // "og" then a place, not a facing
|
||||
['Staður séð til austurs og Vesturlandsvegur', 90],
|
||||
];
|
||||
for (const [text, heading] of cases) {
|
||||
assert.equal(icelandHeadingFromDescription(text), heading, text);
|
||||
}
|
||||
for (const text of [
|
||||
'',
|
||||
null,
|
||||
'Gautsdalur niður á veg',
|
||||
'Miklabraut austur', // a road direction, not a facing
|
||||
'Biskupsháls - norður',
|
||||
'Hringtorg norðan Selfoss',
|
||||
'Norðurárdalur niður á veg', // a place name that contains a cardinal
|
||||
'Staður séð til suðurlands', // a longer word, not "suður"
|
||||
'Staður séð til norðurs og til suðurs', // two facings: ambiguous
|
||||
'Staður séð til austurs og vesturs', // the second facing shares "til"
|
||||
'Staður séð í norður og suður',
|
||||
'Staður séð í norður og í suður',
|
||||
'Norðfjarðargöng séð til Eskifjarðar',
|
||||
]) {
|
||||
assert.ok(Number.isNaN(icelandHeadingFromDescription(text)), String(text));
|
||||
}
|
||||
});
|
||||
|
||||
test('rows off the pinned directory, or with unusable frames or places, are dropped', () => {
|
||||
const bad = [
|
||||
{ Slod: 'https://example.com/vgdata/vefmyndavelar/a_1.jpg' },
|
||||
{ Slod: 'http://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.jpg' },
|
||||
{ Slod: 'https://user:[email protected]/vgdata/vefmyndavelar/a_1.jpg' },
|
||||
{ Slod: 'https://www.vegagerdin.is:8443/vgdata/vefmyndavelar/a_1.jpg' },
|
||||
{ Slod: 'https://www.vegagerdin.is/vgdata/other/a_1.jpg' },
|
||||
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/../x/a_1.jpg' },
|
||||
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a%2Fb_1.jpg' },
|
||||
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.php' },
|
||||
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1' },
|
||||
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/' },
|
||||
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a b_1.jpg' },
|
||||
{ Slod: '' },
|
||||
{ Slod: null },
|
||||
{ Breidd: 0, Lengd: 0 },
|
||||
{ Breidd: null, Lengd: null },
|
||||
{ Breidd: '64.01', Lengd: '-21.4' },
|
||||
{ Breidd: '64.01' }, // each coordinate must be a number on its own
|
||||
{ Lengd: '-21.4' },
|
||||
{ Breidd: 59.9, Lengd: 10.7 }, // Oslo
|
||||
{ Breidd: 64.01, Lengd: 21.4 }, // wrong hemisphere
|
||||
];
|
||||
for (const over of bad) {
|
||||
assert.equal(icelandCameraToSource(row(over)), null, JSON.stringify(over));
|
||||
}
|
||||
assert.equal(icelandCameraToSource(null), null);
|
||||
assert.equal(icelandCameraToSource({}), null);
|
||||
assert.equal(isLikelyIcelandCoordinate(64.1466, -21.9426), true);
|
||||
assert.equal(isLikelyIcelandCoordinate(66.3, -14.0), true);
|
||||
});
|
||||
|
||||
test('the Iceland loader reads the list with redirects refused and keeps the nearest cameras', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: '8' });
|
||||
const rows = Array.from({ length: 12 }, (_, i) =>
|
||||
row({
|
||||
Maelist_nr: 8000 + i,
|
||||
Slod: `${ICELAND_IMAGE_ORIGIN}far_${i}.jpg`,
|
||||
Skyring: `Far ${i}`,
|
||||
Breidd: 66.2,
|
||||
Lengd: -15.9 + i * 0.01,
|
||||
}),
|
||||
);
|
||||
// Reykjavík cameras come last in the feed but first in the priority order.
|
||||
// A frame the feed repeats keeps its last row, as the other packs do.
|
||||
rows.push(row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, Skyring: 'Old' }));
|
||||
rows.push(
|
||||
row({
|
||||
Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`,
|
||||
Skyring: 'Kringlumýrarbraut',
|
||||
Breidd: 64.1466,
|
||||
Lengd: -21.9426,
|
||||
}),
|
||||
);
|
||||
const seen = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return json(rows);
|
||||
});
|
||||
const sources = await loadIcelandSourcesFromOpenData();
|
||||
assert.deepEqual(seen, [[ICELAND_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(sources.length, 8);
|
||||
assert.equal(sources[0].id, 'is-vegagerdin-rvk_1');
|
||||
assert.equal(sources[0].name, 'Kringlumýrarbraut');
|
||||
assert.equal(sources[0].lat, 64.1466);
|
||||
assert.equal(
|
||||
new Set(sources.map((s) => s.id)).size,
|
||||
sources.length,
|
||||
'a frame the feed repeats collapses to one camera',
|
||||
);
|
||||
assert.equal(sources.available, 13);
|
||||
});
|
||||
|
||||
test('the default Iceland cap is DEFAULT_ICELAND_MAX_SOURCES', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined });
|
||||
const rows = Array.from(
|
||||
{ length: DEFAULT_ICELAND_MAX_SOURCES + 40 },
|
||||
(_, i) => row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }),
|
||||
);
|
||||
t.mock.method(globalThis, 'fetch', async () => json(rows));
|
||||
assert.equal(
|
||||
(await loadIcelandSourcesFromOpenData()).length,
|
||||
DEFAULT_ICELAND_MAX_SOURCES,
|
||||
);
|
||||
});
|
||||
|
||||
test('CCTV_ICELAND_MAX_SOURCES is clamped to 8..600 and ignored when not a number', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined });
|
||||
const rows = Array.from({ length: 650 }, (_, i) =>
|
||||
row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }),
|
||||
);
|
||||
t.mock.method(globalThis, 'fetch', async () => json(rows));
|
||||
for (const [value, expected] of [
|
||||
['0', 8],
|
||||
['3', 8],
|
||||
['1000', 600],
|
||||
['abc', DEFAULT_ICELAND_MAX_SOURCES],
|
||||
]) {
|
||||
process.env.CCTV_ICELAND_MAX_SOURCES = value;
|
||||
assert.equal(
|
||||
(await loadIcelandSourcesFromOpenData()).length,
|
||||
expected,
|
||||
value,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a redirect on the Iceland frame host stays inside vefmyndavelar/', async () => {
|
||||
const frame = `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`;
|
||||
const redirectTo = (location) => async (url) =>
|
||||
url === frame
|
||||
? new Response(null, { status: 302, headers: { location } })
|
||||
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
|
||||
for (const location of [
|
||||
'/vgdata/other/x.jpg',
|
||||
'/vgdata/vefmyndavelar/../x.jpg',
|
||||
'/',
|
||||
])
|
||||
assert.equal(
|
||||
await fetchWithinHost(frame, {}, redirectTo(location)),
|
||||
null,
|
||||
location,
|
||||
);
|
||||
const followed = await fetchWithinHost(
|
||||
frame,
|
||||
{},
|
||||
redirectTo('/vgdata/vefmyndavelar/hellisheidi_1b.jpg'),
|
||||
);
|
||||
assert.equal(await followed.text(), 'frame');
|
||||
});
|
||||
|
||||
test('the Iceland loader fails soft on HTTP errors, bad payloads and network errors', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('x', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => json({ rows: [] }));
|
||||
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('<html>', { status: 200 }),
|
||||
);
|
||||
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the Iceland catalog fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: {
|
||||
location: 'http://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1',
|
||||
},
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async () => redirected.response);
|
||||
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const failed = streamingResponse({ status: 500 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||
assert.equal(failed.state.cancelled, true, 'the error body is cancelled');
|
||||
|
||||
const valid = JSON.stringify([row()]);
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
return new Response(valid, {
|
||||
headers: { 'content-length': String(ICELAND_MAX_CATALOG_BYTES + 1) },
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const padded = valid + ' '.repeat(ICELAND_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(padded));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('CCTV catalog merges Iceland cameras and CCTV_ICELAND_ENABLED=0 skips the request', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const sourceRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gev-cctv-catalog-iceland-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: undefined,
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: undefined,
|
||||
CCTV_PREFER_AUSTIN: undefined,
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
CCTV_CALTRANS_DISTRICTS: undefined,
|
||||
CCTV_TFL_ENABLED: undefined,
|
||||
CCTV_ICELAND_ENABLED: undefined,
|
||||
CCTV_ICELAND_MAX_SOURCES: undefined,
|
||||
});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push([String(url), init.redirect]);
|
||||
if (String(url) !== ICELAND_CAMERAS_URL) {
|
||||
return new Response('unavailable', { status: 503 });
|
||||
}
|
||||
return json([row()]);
|
||||
});
|
||||
|
||||
const sources = await createCctvCatalog({ sourceRoot })();
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['is-vegagerdin-hellisheidi_1'],
|
||||
);
|
||||
|
||||
process.env.CCTV_ICELAND_ENABLED = '0';
|
||||
requested.length = 0;
|
||||
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
|
||||
assert.ok(requested.length > 0, 'the other live packs still load');
|
||||
assert.equal(
|
||||
requested.some(([url]) => url === ICELAND_CAMERAS_URL),
|
||||
false,
|
||||
'a disabled pack makes no request',
|
||||
);
|
||||
});
|
||||
Loaded 100 of 327 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user