mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 20:04:00 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab54675ba | ||
|
|
09d833ed96 | ||
|
|
32e08f5043 | ||
|
|
48093108b1 | ||
|
|
9faec3028e | ||
|
|
3534398771 | ||
|
|
690a6637e2 | ||
|
|
434fc9602d | ||
|
|
32ec6ed436 | ||
|
|
0c4da3cdb2 |
No files matched your search
+3
-33
@@ -61,15 +61,8 @@ CESIUM_ION_TOKEN=
|
|||||||
# ChatGPT/Codex OAuth session; the browser still receives only a short-lived
|
# ChatGPT/Codex OAuth session; the browser still receives only a short-lived
|
||||||
# Realtime client secret. Do not prefix with VITE_.
|
# Realtime client secret. Do not prefix with VITE_.
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
# Typed chat uses Chat Completions, independently of the voice model. It can
|
# Optional: forbid API-key voice sessions. Select ChatGPT OAuth in Provider
|
||||||
# only bill OPENAI_API_KEY, so GEV_PREFER_CODEX_OAUTH below turns it off.
|
# Settings; missing or expired Codex sign-in then fails closed.
|
||||||
OPENAI_CHAT_MODEL=gpt-4.1-mini
|
|
||||||
# Typed chat requests (/api/openai/chat) per IP per minute, counted apart from
|
|
||||||
# voice. Unset, it takes GEV_RATELIMIT_OPENAI_PER_MIN's cap; 0 disables it.
|
|
||||||
# Not a billing cap.
|
|
||||||
# GEV_RATELIMIT_OPENAI_CHAT_PER_MIN=30
|
|
||||||
# Optional: forbid API-key voice sessions and OpenAI typed chat. Select ChatGPT
|
|
||||||
# OAuth in Provider Settings; missing or expired Codex sign-in then fails closed.
|
|
||||||
# GEV_PREFER_CODEX_OAUTH=true
|
# GEV_PREFER_CODEX_OAUTH=true
|
||||||
OPENAI_REALTIME_MODEL=gpt-realtime-2
|
OPENAI_REALTIME_MODEL=gpt-realtime-2
|
||||||
# Cheaper voice tier, used when the GEV MIC panel's MINI toggle is on.
|
# Cheaper voice tier, used when the GEV MIC panel's MINI toggle is on.
|
||||||
@@ -218,7 +211,6 @@ AISSTREAM_API_KEY=
|
|||||||
# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL
|
# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL
|
||||||
# CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras
|
# 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
|
# 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_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511
|
||||||
# CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack
|
# CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack
|
||||||
# CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog
|
# CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog
|
||||||
@@ -239,8 +231,6 @@ AISSTREAM_API_KEY=
|
|||||||
# CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL
|
# 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_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_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_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_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
|
# CCTV_HK_CAMERAS_URL= # override the DATA.GOV.HK camera locations XML URL
|
||||||
@@ -266,22 +256,13 @@ AISSTREAM_API_KEY=
|
|||||||
# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras
|
# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras
|
||||||
# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required
|
# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required
|
||||||
# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL
|
# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL
|
||||||
# CCTV_HOUSTON_ENABLED= # off by default; set 1 to load Houston TranStar cameras only if you hold TxDOT's written permission (TranStar's Content Usage terms prohibit copying, distributing or framing its images without it; see DATA_SOURCES.md)
|
|
||||||
# CCTV_HOUSTON_MAX_SOURCES=250 # cap cameras loaded from the Houston TranStar catalog (nearest metro cores first)
|
|
||||||
# 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_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_A22_URL= # override the Open Data Hub A22 webcam catalog URL
|
||||||
# CCTV_OTTAWA_ENABLED=1 # set 0 to skip City of Ottawa cameras (also needs OTTAWA_CAMERA_CERTIFICATE)
|
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||||
# 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_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||||
|
|
||||||
# Optional: comma-separated Overpass URLs; use only instances you run or pay for.
|
# Optional: comma-separated Overpass URLs; use only instances you run or pay for.
|
||||||
# Also enables voice place search (osm_query, "hospitals in Kathmandu").
|
|
||||||
OVERPASS_UPSTREAMS=
|
OVERPASS_UPSTREAMS=
|
||||||
|
|
||||||
# Optional: place-lookup endpoints. Lookups try bundled data first, then the
|
# Optional: place-lookup endpoints. Lookups try bundled data first, then the
|
||||||
@@ -314,14 +295,3 @@ OVERPASS_UPSTREAMS=
|
|||||||
# frame-ancestors sources such as "https://example.com", or * for any page.
|
# frame-ancestors sources such as "https://example.com", or * for any page.
|
||||||
# Unset, no page may frame it. Other pages of the app can never be framed.
|
# Unset, no page may frame it. Other pages of the app can never be framed.
|
||||||
# GEV_EMBED_FRAME_ANCESTORS=
|
# GEV_EMBED_FRAME_ANCESTORS=
|
||||||
|
|
||||||
# Gemini typed chat uses generateContent; voice continues to use Gemini Live.
|
|
||||||
GEMINI_CHAT_MODEL=gemini-3.5-flash-lite
|
|
||||||
# Typed chat requests (/api/gemini/chat) per IP per minute. Unset, it takes
|
|
||||||
# GEV_RATELIMIT_GEMINI_PER_MIN's cap (default 30); 0 disables it.
|
|
||||||
# GEV_RATELIMIT_GEMINI_CHAT_PER_MIN=30
|
|
||||||
|
|
||||||
# Optional keyless Radio Browser coverage: up to 8 ISO alpha-2 countries.
|
|
||||||
# Each reserves up to 40 stations within the existing 750-station directory.
|
|
||||||
# Restart after changing. Leave blank for the existing global selection.
|
|
||||||
# RADIO_PRIORITY_COUNTRIES=IN,PL
|
|
||||||
+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:
|
The highest-leverage places to jump in:
|
||||||
|
|
||||||
- **🌆 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 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 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.
|
- **🛰️ 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).
|
- **🎙️ 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/`.
|
- **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`.
|
||||||
|
|||||||
+10
-37
@@ -40,9 +40,6 @@ How to read this:
|
|||||||
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
|
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
|
||||||
| **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 |
|
| **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)" |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
||||||
@@ -65,16 +62,13 @@ 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" |
|
| **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" |
|
| **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 |
|
| **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) |
|
| **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) |
|
| **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" |
|
| **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" |
|
| **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) |
|
| **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) |
|
| **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) |
|
||||||
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
|
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
|
||||||
| **Houston TranStar** (`traffic.houstontranstar.org`, frames `www.houstontranstar.org`) | CCTV camera catalog + frames, Greater Houston | No licence. The [TranStar disclaimer](https://traffic.houstontranstar.org/disclaimer.aspx) (Content Usage) prohibits copying or distribution, and external framing or linking to specific images, without TxDOT's prior written permission. **Off by default**; enable only if you hold TxDOT's written permission | "Houston TranStar" (courtesy) |
|
|
||||||
| **Autostrada del Brennero (A22)** (catalog `tourism.api.opendatahub.com`, frames `www.autobrennero.it/WebCamImg/`) | CCTV camera catalog + frames, A22 Brenner–Modena motorway, Italy | Public Autobrennero webcam images; camera list republished by the Open Data Hub, whose records are tagged CC0 (`ClosedData: false`, licence holder Autobrennero); Autobrennero publishes no terms of its own for the images | "Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)" (courtesy) |
|
| **Autostrada del Brennero (A22)** (catalog `tourism.api.opendatahub.com`, frames `www.autobrennero.it/WebCamImg/`) | CCTV camera catalog + frames, A22 Brenner–Modena motorway, Italy | Public Autobrennero webcam images; camera list republished by the Open Data Hub, whose records are tagged CC0 (`ClosedData: false`, licence holder Autobrennero); Autobrennero publishes no terms of its own for the images | "Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)" (courtesy) |
|
||||||
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
||||||
| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" |
|
| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" |
|
||||||
@@ -102,11 +96,8 @@ 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) |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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)" |
|
| **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)" |
|
|
||||||
| **OpenSnowMap** (`www.opensnowmap.org`) | Ski Pistes layer: raster overlay of ski runs and lifts (#10) | Tiles CC BY-SA; data ODbL 1.0 via OpenStreetMap; bulk tile download forbidden without prior agreement | "Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)" |
|
|
||||||
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover |
|
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover |
|
||||||
|
|
||||||
### Local receiver input (not fetched from a service)
|
### Local receiver input (not fetched from a service)
|
||||||
@@ -124,11 +115,9 @@ 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.
|
**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.
|
- **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 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).
|
- **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).
|
||||||
- **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)).
|
- **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.
|
|
||||||
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
|
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
|
||||||
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
|
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
|
||||||
<a id="gnss-integrity"></a>
|
<a id="gnss-integrity"></a>
|
||||||
@@ -173,8 +162,6 @@ 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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`.
|
- **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`.
|
- **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`.
|
||||||
@@ -183,7 +170,6 @@ 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`.
|
- **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.
|
- **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`.
|
- **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`.
|
- **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.
|
- **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.
|
- **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.
|
||||||
@@ -191,25 +177,17 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
|||||||
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover.
|
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover.
|
||||||
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover.
|
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover.
|
||||||
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
|
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
|
||||||
- **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`.
|
- **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.
|
- **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 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.
|
- **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.
|
||||||
- **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.** `/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; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful 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.
|
||||||
|
|
||||||
- **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.
|
|
||||||
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
|
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
|
||||||
- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
|
- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
|
||||||
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name.
|
||||||
- **Voice areas and place search.** Voice areas (`resolve_area`) use the same outline sources as voice outlines. Voice place search (`osm_query`, `/api/osm/features`) runs only on an operator-configured Overpass: a fixed list of kinds, a bounded box and result count, one query at a time, answers cached for a day. It never uses Nominatim.
|
|
||||||
- **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and resolves physical regions from bundled public-domain Natural Earth polygons without reverse-geocoding requests. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit.
|
- **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and resolves physical regions from bundled public-domain Natural Earth polygons without reverse-geocoding requests. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit.
|
||||||
- **Dynamic weather presentation.** While cockpit mode is active, `/api/weather-effects` requests current Open-Meteo observations for the aircraft/camera location, rounds coordinates into 0.1° cache cells, caches results for five minutes, and may retain a stale observation for up to 30 minutes during a transient outage. WMO condition code selects the visual family; observed cloud cover, precipitation, visibility, wind speed, and wind direction bound its strength and motion. Missing or expired weather renders no synthetic atmospheric effect, and normal globe view never renders the weather overlay.
|
- **Dynamic weather presentation.** While cockpit mode is active, `/api/weather-effects` requests current Open-Meteo observations for the aircraft/camera location, rounds coordinates into 0.1° cache cells, caches results for five minutes, and may retain a stale observation for up to 30 minutes during a transient outage. WMO condition code selects the visual family; observed cloud cover, precipitation, visibility, wind speed, and wind direction bound its strength and motion. Missing or expired weather renders no synthetic atmospheric effect, and normal globe view never renders the weather overlay.
|
||||||
|
|
||||||
@@ -223,11 +201,9 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
|
|||||||
|
|
||||||
| Dataset | Folder | License | Commercial use? | Attribution |
|
| Dataset | Folder | License | Commercial use? | Attribution |
|
||||||
| --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
|
| --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||||
| **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
|
| **Datacenters** (4,351) | `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 |
|
| **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 |
|
| **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" |
|
| **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) |
|
| **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) |
|
| **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) |
|
||||||
@@ -238,7 +214,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
|
|||||||
| **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) |
|
| **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) |
|
||||||
| **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" |
|
| **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" |
|
||||||
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
|
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
|
||||||
| **CCTV Camera Database** model-enrichment extract v2.26.0 + v2.29.0 (20 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) |
|
| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) |
|
||||||
|
|
||||||
### ⚠️ TeleGeography is bundled but NonCommercial
|
### ⚠️ TeleGeography is bundled but NonCommercial
|
||||||
|
|
||||||
@@ -266,16 +242,13 @@ fixtures retain their provenance in `src/data/fixtures/README.md`.
|
|||||||
|
|
||||||
### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`)
|
### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`)
|
||||||
|
|
||||||
The CCTV layer's hardware-model specs are a small extract (v2.26.0, plus the Bosch MIC
|
The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0) of the
|
||||||
records from v2.29.0) of the
|
|
||||||
[CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of
|
[CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of
|
||||||
camera models transcribed from manufacturer datasheets, dedicated to the public domain under
|
camera models transcribed from manufacturer datasheets, dedicated to the public domain under
|
||||||
**CC0-1.0**. Only records for hardware named by the integrated public catalogs (King County,
|
**CC0-1.0**. Only the records for hardware that the integrated public catalogs name (King
|
||||||
Sarasota County and Sioux Falls) or by the City of Amsterdam register proposed in
|
County, Sarasota County and Sioux Falls) are bundled, compiled into the module. Nothing is
|
||||||
[#251](https://github.com/bilawalsidhu/gods-eye-view/pull/251) are bundled, compiled into the
|
fetched at runtime. The records describe camera products (optics, resolution, night vision)
|
||||||
module. Nothing is fetched at runtime. The MIC records keep their specification fields; their
|
only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD
|
||||||
titles are shortened to model codes and colour aliases are added. The records describe camera
|
|
||||||
products (optics, resolution, night vision) only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD
|
|
||||||
credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders.
|
credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders.
|
||||||
|
|
||||||
### Bundled OpenStreetMap data
|
### Bundled OpenStreetMap data
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat
|
|||||||
|
|
||||||
## ⚡ Quick Start
|
## ⚡ Quick Start
|
||||||
|
|
||||||
**Local setup needs no account or API keys.** The app opens with
|
**Start without an account or API keys.** Every path opens the same app with
|
||||||
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
|
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
|
||||||
unreachable. Flights, military traffic, satellites, earthquakes, public
|
unreachable. Flights, military traffic, satellites, earthquakes, public
|
||||||
cameras, radio, and launches are available without keys.
|
cameras, radio, and launches are available without keys.
|
||||||
@@ -212,7 +212,7 @@ Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front o
|
|||||||
|
|
||||||
On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`.
|
On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`.
|
||||||
|
|
||||||
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). The map routes compare the browser's `Origin` with the `Host` the server sees, so the proxy must pass the original `Host` and, when it terminates TLS, `X-Forwarded-Proto`. Caddy does both by default; for nginx add `proxy_set_header Host $http_host;`, `proxy_set_header X-Forwarded-Proto $scheme;` and `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`. On an install outside Docker, that forwarding header also rate-limits the proxy's users apart from your own browser. Under the default Docker bridge network every client on the host, your browser included, reaches the container from its gateway address and shares one Overpass bucket.
|
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)).
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -236,11 +236,7 @@ the container.
|
|||||||
|
|
||||||
### Path 4 — GitHub Codespaces (cloud, no local install)
|
### Path 4 — GitHub Codespaces (cloud, no local install)
|
||||||
|
|
||||||
Codespaces requires a GitHub account. Overpass queries and OpenAI voice token
|
Runs the same dev server in a cloud workspace, so Node and the dependencies
|
||||||
requests return HTTP 403 through its forwarded HTTPS URL, even with API keys
|
|
||||||
in `.env` and `GEV_ALLOWED_HOSTS` configured. Use a local setup for these features.
|
|
||||||
|
|
||||||
The codespace runs the dev server in a cloud workspace, so Node and the dependencies
|
|
||||||
never touch your machine. The globe still renders in your own browser, so it
|
never touch your machine. The globe still renders in your own browser, so it
|
||||||
needs the same graphics there as any other path. GitHub Free includes 120
|
needs the same graphics there as any other path. GitHub Free includes 120
|
||||||
core-hours a month with no card on file — about 60 hours on the default 2-core
|
core-hours a month with no card on file — about 60 hours on the default 2-core
|
||||||
@@ -269,13 +265,14 @@ ignored on purpose, so pass that exact name.
|
|||||||
|
|
||||||
⚠️ **A codespace is reachable from the internet.** Treat it as the
|
⚠️ **A codespace is reachable from the internet.** Treat it as the
|
||||||
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
|
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
|
||||||
Settings is unavailable on the forwarded URL. Never commit your workspace
|
Settings does not open on the forwarded URL, so put keys in the workspace
|
||||||
`.env`. [SECURITY.md](SECURITY.md) has the threat model.
|
`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the
|
||||||
|
threat model.
|
||||||
|
|
||||||
### Then power it up — in the app, not in a file
|
### Then power it up — in the app, not in a file
|
||||||
|
|
||||||
For a local setup, keys are upgrades, not prerequisites. When you want one,
|
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
|
||||||
click the **POWER UP** chip in the bottom-right corner: Provider Settings lists every supported key,
|
chip in the bottom-right corner: Provider Settings lists every supported key,
|
||||||
what it switches on, and where to get it. Paste, hit **SAVE KEYS**, and the app
|
what it switches on, and where to get it. Paste, hit **SAVE KEYS**, and the app
|
||||||
restarts itself with the new capability on. Once everything is configured the
|
restarts itself with the new capability on. Once everything is configured the
|
||||||
chip reads **POWERED UP** — and if a compact layout hides it, `?setup=1`
|
chip reads **POWERED UP** — and if a compact layout hides it, `?setup=1`
|
||||||
@@ -362,11 +359,9 @@ _Why cockpit mode exists: you're riding a real aircraft over real terrain — an
|
|||||||
|
|
||||||
## 🎙️ Talk to It
|
## 🎙️ Talk to It
|
||||||
|
|
||||||
The standalone app offers **OpenAI** and **Gemini** under **AI Provider** in Settings, one choice shared by voice and chat. OpenAI is the initial default; your choice is saved in this browser. To use Gemini, add `GEMINI_API_KEY` in Provider Settings or your local `.env` file, then select Gemini. `GEMINI_LIVE_MODEL` defaults to `gemini-3.8-live`. The permanent key stays on the local server, which issues a constrained, single-use session credential.
|
The standalone app offers **OpenAI** and **Gemini** in the GEV MIC provider chooser. OpenAI is the initial default; your choice is saved in this browser. To use Gemini, add `GEMINI_API_KEY` in Provider Settings or your local `.env` file, then select Gemini. `GEMINI_LIVE_MODEL` defaults to `gemini-3.8-live`. The permanent key stays on the local server, which issues a constrained, single-use session credential.
|
||||||
|
|
||||||
Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The AI Provider setting does not change the OpenAI-backed AI HUD summary.
|
Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The provider chooser does not change the OpenAI-backed AI HUD summary.
|
||||||
|
|
||||||
Next to the mic, **CHAT** opens a typed chat with silent replies. It uses the selected provider and the same map actions, and never asks for the microphone. OpenAI chat needs an API key (`OPENAI_CHAT_MODEL`), so it is off while `GEV_PREFER_CODEX_OAUTH` pins OpenAI to ChatGPT OAuth; Gemini chat uses a separate text model (`GEMINI_CHAT_MODEL`). Chat and voice panels share the left panels' glass surface and sit above the dock or dock to the right.
|
|
||||||
|
|
||||||
> Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
|
> Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
|
||||||
|
|
||||||
@@ -426,7 +421,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station
|
|||||||
|
|
||||||
## 🛰️ What's on the Globe
|
## 🛰️ What's on the Globe
|
||||||
|
|
||||||
Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
|
Twenty layers and map sources. **Eighteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
|
||||||
|
|
||||||
| Layer | What you get | Source | Auth |
|
| Layer | What you get | Source | Auth |
|
||||||
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
@@ -440,26 +435,23 @@ Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer ad
|
|||||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
| 🌍 **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 | 🟢 |
|
| ◍ **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 |
|
| 🚗 **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** | 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 | 🟢 |
|
| 📹 **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) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
| 📷 **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 |
|
| 📷 **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 | 🟢 |
|
| 📻 **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, 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 | 🟢 |
|
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
|
||||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
| 🚲 **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) | 🟢 |
|
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
|
||||||
| ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise |
|
|
||||||
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
||||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) |
|
| 🚀 **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 | 🟢 |
|
| 🎖️ **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 | 🟢 |
|
| 🌬️ **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 | 🟢 |
|
| 🌦️ **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 | 🟢 |
|
| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 |
|
||||||
|
|
||||||
**Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view.
|
**Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view.
|
||||||
|
|
||||||
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Audio; Directions and OSM Places sit in the separate **Utilities** panel with Draw and Import.
|
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Audio; Directions sits in the separate **Utilities** panel with Draw and Import.
|
||||||
|
|
||||||
**The basemap ladder — what each tier buys you:**
|
**The basemap ladder — what each tier buys you:**
|
||||||
|
|
||||||
@@ -473,7 +465,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×._
|
_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,352), Dams (704), and Submarine Cables (712).
|
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,351), Dams (704), and Submarine Cables (712).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -485,9 +477,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).
|
> 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 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.
|
**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.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
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`.
|
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`.
|
||||||
|
|
||||||
@@ -639,7 +631,7 @@ and panel. DelDOT uses its official HTTPS HLS catalog links. Disable that
|
|||||||
pack with `CCTV_DELDOT_ENABLED=0`.
|
pack with `CCTV_DELDOT_ENABLED=0`.
|
||||||
|
|
||||||
The server allows two concurrent sessions. Each retains at most 12 segments
|
The server allows two concurrent sessions. Each retains at most 12 segments
|
||||||
and 24 MiB in memory; individual downloads are capped at 8 MiB with a ten
|
and 24 MiB in memory; individual downloads are capped at 4 MiB with a ten
|
||||||
second deadline. There are no segment files or ffmpeg processes. Redirects,
|
second deadline. There are no segment files or ffmpeg processes. Redirects,
|
||||||
off-origin references, encrypted playlists and non-MPEG-TS segments are refused.
|
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
|
Each decoder has its own client lease (at most eight per session), including
|
||||||
|
|||||||
+4
-7
@@ -69,25 +69,22 @@ 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:
|
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.
|
- **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 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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **Response-size caps and timeouts** on proxied responses.
|
||||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
- **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.
|
- **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/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.
|
- **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 terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. 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.
|
- **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.
|
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.
|
||||||
- **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, 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.
|
- **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, 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.
|
||||||
- **Typed chat spends the provider keys.** `/api/openai/chat` and `/api/gemini/chat` send the conversation to OpenAI Chat Completions or Gemini `generateContent` with the server's `OPENAI_API_KEY` or `GEMINI_API_KEY`. They require a same-site `Origin` (the Gemini request check), cap request bodies at 512 KB, refuse upstream redirects, cut the upstream call off after 60 s and return only sanitized errors. OpenAI chat can only bill `OPENAI_API_KEY`, so while `GEV_PREFER_CODEX_OAUTH` pins OpenAI to ChatGPT OAuth it refuses with `503` (`CODEX_OAUTH_REQUIRED`) rather than spend the key. After the provider refuses the key or account (a `401`, a `403`, Gemini's `400` `API_KEY_INVALID` or OpenAI's `429` `insufficient_quota`), a route stops sending that key upstream for a minute, or until the key changes.
|
|
||||||
- **ChatGPT OAuth voice reuses the local Codex sign-in.** `/api/realtime/oauth-status`, `/api/realtime/oauth-login` and `/api/realtime/token?auth=oauth` answer only loopback connections, behind the same cross-site gate as the token endpoint; the login route also refuses a foreign `Origin`. None of them returns the access token. The login route runs `codex login` (or the executable named by `CODEX_BIN`) with an allowlisted environment that leaves out GEV's provider keys, keeps one attempt pending at a time and cancels it after two minutes or when the server stops. **Limits:** any program on this machine that can reach the server can mint Realtime sessions on the signed-in ChatGPT account, as it can with the API key; a local reverse proxy that hides the client's address defeats the loopback check; and OpenAI has not documented this use of Codex credentials for Realtime, so it may stop working or fall outside the account's terms.
|
- **ChatGPT OAuth voice reuses the local Codex sign-in.** `/api/realtime/oauth-status`, `/api/realtime/oauth-login` and `/api/realtime/token?auth=oauth` answer only loopback connections, behind the same cross-site gate as the token endpoint; the login route also refuses a foreign `Origin`. None of them returns the access token. The login route runs `codex login` (or the executable named by `CODEX_BIN`) with an allowlisted environment that leaves out GEV's provider keys, keeps one attempt pending at a time and cancels it after two minutes or when the server stops. **Limits:** any program on this machine that can reach the server can mint Realtime sessions on the signed-in ChatGPT account, as it can with the API key; a local reverse proxy that hides the client's address defeats the loopback check; and OpenAI has not documented this use of Codex credentials for Realtime, so it may stop working or fall outside the account's terms.
|
||||||
|
|
||||||
## Network exposure — the operator threat model
|
## Network exposure — the operator threat model
|
||||||
@@ -96,7 +93,7 @@ The dev server is a **key broker**: every server-side key above is spendable by
|
|||||||
|
|
||||||
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks). Binding to all interfaces keeps that restriction: IP addresses and `localhost` work, and LAN hostnames must be listed in `GEV_ALLOWED_HOSTS` (for example `GEV_ALLOWED_HOSTS=globe.lan`); suffix and wildcard entries are ignored.
|
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks). Binding to all interfaces keeps that restriction: IP addresses and `localhost` work, and LAN hostnames must be listed in `GEV_ALLOWED_HOSTS` (for example `GEV_ALLOWED_HOSTS=globe.lan`); suffix and wildcard entries are ignored.
|
||||||
- **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust.
|
- **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust.
|
||||||
- **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Typed chat keeps its own per-IP window, set by `GEV_RATELIMIT_OPENAI_CHAT_PER_MIN` and `GEV_RATELIMIT_GEMINI_CHAT_PER_MIN`; left unset, each takes its provider's resolved cap (`GEV_RATELIMIT_OPENAI_PER_MIN`, or `GEV_RATELIMIT_GEMINI_PER_MIN`, default `30`), so lowering a provider throttle lowers chat too, while an explicit chat value, including `0`, wins. Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**.
|
- **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**.
|
||||||
- **Docker trusts one address, and only because the port is on loopback.**
|
- **Docker trusts one address, and only because the port is on loopback.**
|
||||||
Docker delivers the host's own browser to the container from the container
|
Docker delivers the host's own browser to the container from the container
|
||||||
network's gateway, never from `127.0.0.1`, so a loopback-only Provider
|
network's gateway, never from `127.0.0.1`, so a loopback-only Provider
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
- Add a Prohibited & Restricted Airspace layer for FAA prohibited (P) and
|
|
||||||
restricted (R) special-use airspace. The keyless `/api/drone-airspace` proxy
|
|
||||||
pages the public SUA FeatureServer, keeps a one-hour snapshot, and serves
|
|
||||||
stale data when a refresh fails. Zones draw as ground polygons (red
|
|
||||||
prohibited, orange restricted); selection shows name, altitude range, times
|
|
||||||
of use and agency, and the row legend states the layer is informational only
|
|
||||||
and does not include the UAS Facility Map or UAS flight restrictions. Off by
|
|
||||||
default; share-link token `8` (mvanhorn, #1086).
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
- Add Houston traffic cameras from Houston TranStar as a CCTV pack. The camera
|
|
||||||
list is one keyless JSON request to
|
|
||||||
`traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js`; frames are
|
|
||||||
stills synthesized from each numeric camera id on `www.houstontranstar.org`,
|
|
||||||
so a catalog field cannot steer the proxy off-host. Cameras without a
|
|
||||||
snapshot id, and cameras outside Greater Houston, are dropped. TranStar's
|
|
||||||
`dir` field (the side of the freeway the camera sits on) seeds a
|
|
||||||
low-confidence heading along the nearest lanes' travel direction. TranStar's
|
|
||||||
Content Usage terms (traffic.houstontranstar.org/disclaimer.aspx) prohibit
|
|
||||||
copying, distribution and external framing of its images without TxDOT's
|
|
||||||
written permission, so the pack is off by default; operators should set
|
|
||||||
`CCTV_HOUSTON_ENABLED=1` only if they hold that permission. Frames are
|
|
||||||
relayed on demand with `no-store` and not archived. Once enabled, it loads
|
|
||||||
the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and
|
|
||||||
Galveston; `CCTV_HOUSTON_MAX_SOURCES` changes that cap.
|
|
||||||
Snapshots update about every 3 minutes. The catalog-wide ceiling is
|
|
||||||
unchanged; extra cameras share it round-robin with the other packs
|
|
||||||
(mvanhorn, #1125).
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
- Add Sea Marks (OpenSeaMap) and Ski Pistes (OpenSnowMap) as two raster
|
|
||||||
overlay layers under Infrastructure. They drape on the globe stacks and on
|
|
||||||
Google 3D, and share links keep each one (Tom-Neverwinter, #114).
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
- Add optional keyless Radio priority-country coverage with up to 40 stations
|
|
||||||
per configured country inside the existing 750-station cap; the UK alias uses
|
|
||||||
the canonical GB code. Country query
|
|
||||||
failures do not block a healthy global catalog. Bound mirror attempts to three
|
|
||||||
and retry failed or partial refreshes after 60 seconds on all installations,
|
|
||||||
including those without priority countries configured (m3ayush, #1206).
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
- Keep Overpass-backed layers working behind a reverse proxy: `/api/overpass`
|
|
||||||
now uses the keyless same-site gate, admits forwarding headers and still
|
|
||||||
refuses cross-site requests. Direct loopback requests keep their own
|
|
||||||
admission window; proxied requests, including those a local reverse proxy
|
|
||||||
relays over loopback, are admitted as remote traffic keyed by their socket,
|
|
||||||
and IPv6 peers are keyed by /64, so neither LAN peers nor proxied users can
|
|
||||||
exhaust the owner's admission. The upstream budget stays shared, so an owner
|
|
||||||
cache miss can still be rate-limited (asturksever, #1308).
|
|
||||||
- Give the tracking arrival check a wall-clock budget per observed frame and
|
|
||||||
retry only an inconclusive timeout: a timeout keeps every angle it
|
|
||||||
measured, including a late frame's, and a wrong one fails without retry, so slow software-rendered
|
|
||||||
frames no longer fail the check and a real violation is never retried into a
|
|
||||||
pass (asturksever, #1308).
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
- Vendor the Bosch MIC PTZ datasheet records that the City of Amsterdam camera
|
|
||||||
register names (MIC-7522, MIC-7502, MIC-7130), with the register's `x` colour
|
|
||||||
wildcard as an alias, from the CCTV Camera Database v2.29.0 (ch-bas, #1319).
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
- Color every Active Fires detection by fire radiative power on a six-stop
|
|
||||||
ember → white-hot ramp (ember · red · flame · orange · gold · white-hot at
|
|
||||||
0 · 5 · 20 · 50 · 100 · 300 MW, brighter as power rises), and add a
|
|
||||||
log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ /
|
|
||||||
High confidence switch and an "N of M shown" counter to the layer row. Far
|
|
||||||
zoom shows a Snap Map–style heatmap in the same colors instead of squares,
|
|
||||||
one distinct spot per fire, empty where nothing burns, and cross-fades with
|
|
||||||
the markers around 4,500 km with no gap, keeping weak fires as markers. The filter applies once before
|
|
||||||
rendering, so markers, cards, the heatmap, context and the top-fires query
|
|
||||||
agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires`
|
|
||||||
takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All
|
|
||||||
(gekh, #1426).
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
- Military aircraft that leave the adsb.lol feed fade and are removed again.
|
|
||||||
The feed's routine position-less rows no longer mark every snapshot partial,
|
|
||||||
which kept vanished aircraft at full opacity for up to five minutes; those
|
|
||||||
rows keep only their own aircraft, and a likely-landed one still leaves on
|
|
||||||
the landed limit (Aditya, @Rohilalala, #1435).
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
- A terrain request the proxy can only partly answer (a 429 pause, or a point
|
|
||||||
the upstream returns without a height) now keeps the real heights it has
|
|
||||||
instead of putting the whole chunk on the geoid (quanticsoul4772, #1456).
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
- Ground-sample Data Centers and Dams markers only when the camera sees the
|
|
||||||
ground under them. Unseen ground read through the Earth and retried every
|
|
||||||
2 s; over Ashburn, Virginia on the Esri map, slow frames reached 2 s
|
|
||||||
(quanticsoul4772, #1464).
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
- Radio Next, Previous and the tuning dial no longer start a broadcaster stream
|
|
||||||
before Play is pressed; while a stream plays, it follows the new station.
|
|
||||||
Play reads Pause while a stream connects, and a tuner release onto a station
|
|
||||||
the directory removed or changed no longer leaves a Resume for another
|
|
||||||
station (gekh, #1474).
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
- Write the AI HUD summary in the interface language, led by the HUD's own
|
|
||||||
translated feed-state warning. A partial layer no longer reads as off or
|
|
||||||
nominal in the HUD, voice and analyst answers (German, @gekh, #1488).
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
- Street Traffic gives the two TomTom `one_side` lines of one two-way street
|
|
||||||
a two-way direction's half share of simulated vehicles each, so the street
|
|
||||||
is no longer drawn twice as dense as the same street from OpenStreetMap
|
|
||||||
(quanticsoul4772, #1495).
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
- 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.
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
- Add a typed AI chat beside the voice mic in the command dock. Replies are
|
|
||||||
silent and never use the microphone; messages run the same map actions as
|
|
||||||
voice through OpenAI Chat Completions (`OPENAI_CHAT_MODEL`) or Gemini
|
|
||||||
`generateContent` (`GEMINI_CHAT_MODEL`). Chat is throttled per IP by
|
|
||||||
`GEV_RATELIMIT_OPENAI_CHAT_PER_MIN` or `GEV_RATELIMIT_GEMINI_CHAT_PER_MIN`,
|
|
||||||
which default to that provider's voice throttle, and `GEV_PREFER_CODEX_OAUTH`
|
|
||||||
turns OpenAI chat off. The chat and voice panels minimize to a bar or dock
|
|
||||||
beside the right rail, and step aside for the Location and Visual Presets
|
|
||||||
trays (Manjunath, @manjunath22466, with Darren Dignam, @darrendignam, #1507).
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- One world clock now drives everything with a time of its own: the sky,
|
||||||
|
the HUD clock, satellites, launches and weather. A timeline along the
|
||||||
|
bottom of the screen, shown while one of those layers is on, plays, pauses
|
||||||
|
and scrubs it, with a tick for each launch or frame and a way back to
|
||||||
|
live. Share links made away from live carry the time (samehkhamis, #1512).
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
- Draw the Global Context 250 km range ring as a 4 px line with a dark halo,
|
|
||||||
so it stays visible over desert, snow, ocean and forest and under the FLIR,
|
|
||||||
NVG and CRT styles, where the old 1 px outline disappeared (manjunath22466,
|
|
||||||
#1518).
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
- The Global Context range ring pulses like a sonar: three dim rings with a
|
|
||||||
soft inward glow grow from the subject to 250 km, and the ring flashes as
|
|
||||||
each one arrives. Flights, military aircraft and AIS vessels a pulse crosses
|
|
||||||
get a brief echo around their brackets. The pulses run for about 9 s after
|
|
||||||
a selection or camera move, dimming the ring between flashes, then rest
|
|
||||||
with the ring steady on screen, so a parked view stops repainting. Under
|
|
||||||
reduced motion there are no pulses and the ring stays on screen
|
|
||||||
(manjunath22466, #1519).
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
- Keep the globe on screen when zooming out from a tilted view. User zoom is
|
|
||||||
capped at 40,000 km, and above 300 km outward zoom pitches the view down so
|
|
||||||
Earth stays framed. Correction finishes when zoom stops, respects tilt
|
|
||||||
gestures and stricter host limits, and yields to other camera actions
|
|
||||||
(asturksever, #1522).
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
- Keep retained Active Fires heat imagery hidden when an empty filtered layer is
|
|
||||||
re-enabled while a new snapshot is pending (radianx, #1535).
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
- Finish the typed AI chat translations. The "Reset to globe view" and "Show
|
|
||||||
earthquakes" quick commands are now translated, Traditional Chinese follows
|
|
||||||
Simplified Chinese again, Arabic docks the panel to the side it actually
|
|
||||||
lands on, and the new strings use each language's glossary terms
|
|
||||||
(German, @gekh, #1545).
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
- Chat autocomplete finds Arabic suggestions typed without short vowels or
|
|
||||||
shadda, so «تتبع» now suggests «اعرض تتبّع الرحلات المباشر»
|
|
||||||
(German, @gekh, #1561).
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
- 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)
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
- Voice can reuse what it outlines. `resolve_area` turns a named place into a
|
|
||||||
reusable area, keeping every part and hole, from the same sources as voice
|
|
||||||
outlines; a name several countries or states share ("Georgia", "Punjab") is
|
|
||||||
asked about unless the view is inside one of them. "Draw an area around
|
|
||||||
<landmark>" outlines the mapped area enclosing it, or a 250 m buffer marked
|
|
||||||
approximate. `analyst_query` counts inside an area, the area you drew or an
|
|
||||||
outline on the map, and `find_imagery` shows the best recent acquisition for
|
|
||||||
an area, the view or the spot under the pointer (manjunath22466, #846).
|
|
||||||
- `osm_query` ("hospitals in Kathmandu") lists places of one kind into a
|
|
||||||
session-only OSM Places layer. It is offered only when `OVERPASS_UPSTREAMS`
|
|
||||||
is set, and `/api/osm/features` refuses before fetching otherwise; public
|
|
||||||
Nominatim is never used for it (manjunath22466, #846).
|
|
||||||
- Analyst counts that combine flights, military aircraft and local ADS-B are
|
|
||||||
named "aircraft". Follow-ups keep the displayed scope and the loaded-data
|
|
||||||
caveat until a new scope replaces them (manjunath22466, #846).
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
- Add Troll Housing to the Datacenters layer (4,352 features) by refreshing
|
|
||||||
Norway from OpenStreetMap data of 2026-10-05 (trygvehhh, #953).
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"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
@@ -1,155 +0,0 @@
|
|||||||
# 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).
|
|
||||||
+102
-662
File diff suppressed because it is too large.
Load diff
@@ -57,6 +57,6 @@ Consumers use the package exports, which do not import standalone application
|
|||||||
globals.
|
globals.
|
||||||
|
|
||||||
Dataset files and source/license notices remain under `src/data/local_data/`.
|
Dataset files and source/license notices remain under `src/data/local_data/`.
|
||||||
Their input counts are 4,352 datacenter features and 704 dam features. Cesium may
|
Their input counts are 4,351 datacenter features and 704 dam features. Cesium may
|
||||||
expand multipart geometries into multiple entities; entity and stem counts are
|
expand multipart geometries into multiple entities; entity and stem counts are
|
||||||
different measurements. This change does not refresh or relicense the datasets.
|
different measurements. This change does not refresh or relicense the datasets.
|
||||||
@@ -10,24 +10,6 @@ For the roadmap and open backlog, see the repository issue tracker.
|
|||||||
|
|
||||||
## Open
|
## 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
|
### Street traffic can be slow/uneven when panning across dense city blocks
|
||||||
Status: Open (partially mitigated)
|
Status: Open (partially mitigated)
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
|
|||||||
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
|
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
|
||||||
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
|
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
|
||||||
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
|
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
|
||||||
|
| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z |
|
||||||
|
|
||||||
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
|
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
|
||||||
so v1 never writes it; a later view kind writes its code.
|
so v1 never writes it; a later view kind writes its code.
|
||||||
@@ -132,6 +133,10 @@ style also extends the pinned style-defaults table.
|
|||||||
`hudVariant` code 1 (`operator`) is retired: the app no longer offers that
|
`hudVariant` code 1 (`operator`) is retired: the app no longer offers that
|
||||||
layout and restores it as `tactical`. The code stays reserved.
|
layout and restores it as `tactical`. The code stays reserved.
|
||||||
|
|
||||||
|
Field 28 is the world time the link was made at, written only when it was away
|
||||||
|
from live. A link without it opens live. Unlike field 8 it may lie ahead of the
|
||||||
|
link's creation (a forecast moment), and it opens paused at that time.
|
||||||
|
|
||||||
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
|
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
|
||||||
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
|
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ The session adapter, action runner and backend interfaces remain unchanged.
|
|||||||
| `pointerContext.js` | Pointer position, dwell pick and the current turn's pointer snapshot (session-scoped) |
|
| `pointerContext.js` | Pointer position, dwell pick and the current turn's pointer snapshot (session-scoped) |
|
||||||
| `referents.js` | Numbered items from the latest result for `referent:n` (session-scoped) |
|
| `referents.js` | Numbered items from the latest result for `referent:n` (session-scoped) |
|
||||||
| `resultDisplay.js` | What the voice card shows for a result and which items it numbers (read by the card and `referents.js`) |
|
| `resultDisplay.js` | What the voice card shows for a result and which items it numbers (read by the card and `referents.js`) |
|
||||||
| `gevActions.js` runner | Area handles (page lifetime); analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) |
|
| `gevActions.js` runner | Analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) |
|
||||||
|
|
||||||
The backend is replaceable per deployment: `realtimeController.js` accepts it as a
|
The backend is replaceable per deployment: `realtimeController.js` accepts it as a
|
||||||
`backend` option and falls back to `createRealtimeBackend()`. Its two method
|
`backend` option and falls back to `createRealtimeBackend()`. Its two method
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@
|
|||||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
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
|
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. -->
|
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,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" />
|
<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,pause,pentagon,play_arrow,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" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||||
|
|||||||
+3
-7
@@ -2,7 +2,7 @@
|
|||||||
"name": "gods-eye-view",
|
"name": "gods-eye-view",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"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.",
|
"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.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Bilawal Sidhu",
|
"author": "Bilawal Sidhu",
|
||||||
@@ -47,7 +47,6 @@
|
|||||||
"build:panel": "node scripts/build-panel.mjs",
|
"build:panel": "node scripts/build-panel.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "node scripts/run-unit-tests.mjs",
|
"test": "node scripts/run-unit-tests.mjs",
|
||||||
"test:perf": "node scripts/run-perf-tests.mjs",
|
|
||||||
"test:track": "node scripts/track-regression.mjs",
|
"test:track": "node scripts/track-regression.mjs",
|
||||||
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
|
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
|
||||||
"qa:street-level": "node scripts/qa-street-level.mjs",
|
"qa:street-level": "node scripts/qa-street-level.mjs",
|
||||||
@@ -90,6 +89,7 @@
|
|||||||
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
|
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
|
||||||
"./application": "./src/app/application.js",
|
"./application": "./src/app/application.js",
|
||||||
"./application/viewer": "./src/app/viewer.js",
|
"./application/viewer": "./src/app/viewer.js",
|
||||||
|
"./time": "./src/time/worldClock.js",
|
||||||
"./build/vite": {
|
"./build/vite": {
|
||||||
"node": "./build/vite.js"
|
"node": "./build/vite.js"
|
||||||
},
|
},
|
||||||
@@ -177,7 +177,6 @@
|
|||||||
"./layers/perimeters": "./src/layers/perimeters/index.js",
|
"./layers/perimeters": "./src/layers/perimeters/index.js",
|
||||||
"./layers/gnss": "./src/layers/gnss/index.js",
|
"./layers/gnss": "./src/layers/gnss/index.js",
|
||||||
"./layers/ghostmaps": "./src/layers/ghostMaps/index.js",
|
"./layers/ghostmaps": "./src/layers/ghostMaps/index.js",
|
||||||
"./layers/drone-airspace": "./src/layers/droneAirspace/index.js",
|
|
||||||
"./sources/firms-csv": "./src/data/firmsCsv.js",
|
"./sources/firms-csv": "./src/data/firmsCsv.js",
|
||||||
"./layers/firms": "./src/layers/firms/index.js",
|
"./layers/firms": "./src/layers/firms/index.js",
|
||||||
"./layers/satellites": "./src/layers/satellites/index.js",
|
"./layers/satellites": "./src/layers/satellites/index.js",
|
||||||
@@ -257,7 +256,6 @@
|
|||||||
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
|
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
|
||||||
"./layers/gnss/source": "./src/layers/gnss/source.js",
|
"./layers/gnss/source": "./src/layers/gnss/source.js",
|
||||||
"./layers/ghostmaps/source": "./src/layers/ghostMaps/source.js",
|
"./layers/ghostmaps/source": "./src/layers/ghostMaps/source.js",
|
||||||
"./layers/drone-airspace/source": "./src/layers/droneAirspace/source.js",
|
|
||||||
"./layers/firms/source": "./src/layers/firms/source.js",
|
"./layers/firms/source": "./src/layers/firms/source.js",
|
||||||
"./layers/installations/source": "./src/layers/installations/source.js",
|
"./layers/installations/source": "./src/layers/installations/source.js",
|
||||||
"./layers/launches/source": "./src/layers/launches/source.js",
|
"./layers/launches/source": "./src/layers/launches/source.js",
|
||||||
@@ -293,8 +291,6 @@
|
|||||||
"./tools/panel": "./src/tools/panel.js",
|
"./tools/panel": "./src/tools/panel.js",
|
||||||
"./tools/services": "./src/tools/services.js",
|
"./tools/services": "./src/tools/services.js",
|
||||||
"./view": "./src/view/index.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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,3 @@ PINOKIO_SHARE_VAR=__gev_sharing_disabled__
|
|||||||
# App-level guards, not billing caps. Provider-side budgets remain authoritative.
|
# App-level guards, not billing caps. Provider-side budgets remain authoritative.
|
||||||
GEV_RATELIMIT_OPENAI_PER_MIN=30
|
GEV_RATELIMIT_OPENAI_PER_MIN=30
|
||||||
GEV_RATELIMIT_GOOGLE_PER_MIN=120
|
GEV_RATELIMIT_GOOGLE_PER_MIN=120
|
||||||
# Typed chat follows the OpenAI cap above, or Gemini voice's default of 30,
|
|
||||||
# unless set here; 0 disables it.
|
|
||||||
# GEV_RATELIMIT_OPENAI_CHAT_PER_MIN=
|
|
||||||
# GEV_RATELIMIT_GEMINI_CHAT_PER_MIN=
|
|
||||||
@@ -28,8 +28,6 @@ module.exports = {
|
|||||||
LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}',
|
LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}',
|
||||||
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
|
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
|
||||||
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
|
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
|
||||||
GEV_RATELIMIT_OPENAI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_CHAT_PER_MIN || ""}}',
|
|
||||||
GEV_RATELIMIT_GEMINI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_GEMINI_CHAT_PER_MIN || ""}}',
|
|
||||||
},
|
},
|
||||||
message: 'node scripts/pinokio-install.mjs',
|
message: 'node scripts/pinokio-install.mjs',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ module.exports = {
|
|||||||
PINOKIO_SHARE_VAR: '{{env.PINOKIO_SHARE_VAR || "__gev_sharing_disabled__"}}',
|
PINOKIO_SHARE_VAR: '{{env.PINOKIO_SHARE_VAR || "__gev_sharing_disabled__"}}',
|
||||||
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
|
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
|
||||||
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
|
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
|
||||||
GEV_RATELIMIT_OPENAI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_CHAT_PER_MIN || ""}}',
|
|
||||||
GEV_RATELIMIT_GEMINI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_GEMINI_CHAT_PER_MIN || ""}}',
|
|
||||||
},
|
},
|
||||||
message: 'node scripts/pinokio-start.mjs',
|
message: 'node scripts/pinokio-start.mjs',
|
||||||
on: [{
|
on: [{
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ module.exports = {
|
|||||||
LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}',
|
LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}',
|
||||||
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
|
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
|
||||||
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
|
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
|
||||||
GEV_RATELIMIT_OPENAI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_CHAT_PER_MIN || ""}}',
|
|
||||||
GEV_RATELIMIT_GEMINI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_GEMINI_CHAT_PER_MIN || ""}}',
|
|
||||||
},
|
},
|
||||||
message: 'node scripts/pinokio-update.mjs',
|
message: 'node scripts/pinokio-update.mjs',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"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_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}"
|
||||||
CCTV_CALGARY_ENABLED="${CCTV_CALGARY_ENABLED:-1}"
|
CCTV_CALGARY_ENABLED="${CCTV_CALGARY_ENABLED:-1}"
|
||||||
CCTV_CALGARY_MAX_SOURCES="${CCTV_CALGARY_MAX_SOURCES:-220}"
|
CCTV_CALGARY_MAX_SOURCES="${CCTV_CALGARY_MAX_SOURCES:-220}"
|
||||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-7350}"
|
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-4000}"
|
||||||
|
|
||||||
# Capture which provider credentials genuinely came from the parent shell
|
# Capture which provider credentials genuinely came from the parent shell
|
||||||
# before this launcher resolves dotenv and Keychain fallbacks. Only names are
|
# before this launcher resolves dotenv and Keychain fallbacks. Only names are
|
||||||
|
|||||||
+1
-155
@@ -57,9 +57,6 @@
|
|||||||
"src/app/mapLifecycle.test.mjs",
|
"src/app/mapLifecycle.test.mjs",
|
||||||
"src/app/startupCamera.test.mjs",
|
"src/app/startupCamera.test.mjs",
|
||||||
"src/app/viewer.js",
|
"src/app/viewer.js",
|
||||||
"src/app/zoomOutGuard.test.mjs",
|
|
||||||
"src/app/controls.test.mjs",
|
|
||||||
"scripts/qa-zoom-out-guard.mjs",
|
|
||||||
"src/app/atmosphereCompat.test.mjs",
|
"src/app/atmosphereCompat.test.mjs",
|
||||||
"src/standalone/application.js",
|
"src/standalone/application.js",
|
||||||
"src/standalone/controls.js",
|
"src/standalone/controls.js",
|
||||||
@@ -347,7 +344,6 @@
|
|||||||
"src/layers/flights/tracking.js",
|
"src/layers/flights/tracking.js",
|
||||||
"src/layers/flights/rendering.js",
|
"src/layers/flights/rendering.js",
|
||||||
"src/layers/flights/testing.js",
|
"src/layers/flights/testing.js",
|
||||||
"src/layers/flights/hiddenContactRotation.test.mjs",
|
|
||||||
"src/layers/flights/evidence.js",
|
"src/layers/flights/evidence.js",
|
||||||
"src/layers/flights/ingestion.js",
|
"src/layers/flights/ingestion.js",
|
||||||
"src/layers/flights/queries.js",
|
"src/layers/flights/queries.js",
|
||||||
@@ -406,22 +402,12 @@
|
|||||||
"src/layers/perimeters/cards.test.mjs",
|
"src/layers/perimeters/cards.test.mjs",
|
||||||
"src/layers/perimeters/inciweb.test.mjs",
|
"src/layers/perimeters/inciweb.test.mjs",
|
||||||
"src/layers/perimeters/ownership.test.mjs",
|
"src/layers/perimeters/ownership.test.mjs",
|
||||||
"src/layers/droneAirspace/index.js",
|
|
||||||
"src/layers/droneAirspace/records.js",
|
|
||||||
"src/layers/droneAirspace/source.js",
|
|
||||||
"src/layers/droneAirspace/cards.js",
|
|
||||||
"src/layers/droneAirspace/records.test.mjs",
|
|
||||||
"src/layers/droneAirspace/source.test.mjs",
|
|
||||||
"src/layers/droneAirspace/cards.test.mjs",
|
|
||||||
"src/layers/droneAirspace/ownership.test.mjs",
|
|
||||||
"src/layers/droneAirspace/docs.test.mjs",
|
|
||||||
"src/data/firmsHeatmap.js",
|
"src/data/firmsHeatmap.js",
|
||||||
"src/data/fireAnchors.js",
|
"src/data/fireAnchors.js",
|
||||||
"src/layers/firms/anchors.js",
|
"src/layers/firms/anchors.js",
|
||||||
"src/layers/firms/cards.js",
|
"src/layers/firms/cards.js",
|
||||||
"src/layers/firms/index.js",
|
"src/layers/firms/index.js",
|
||||||
"src/layers/firms/ingestion.js",
|
"src/layers/firms/ingestion.js",
|
||||||
"src/layers/firms/heatVisibility.test.mjs",
|
|
||||||
"src/layers/firms/lifecycle.js",
|
"src/layers/firms/lifecycle.js",
|
||||||
"src/layers/firms/model.js",
|
"src/layers/firms/model.js",
|
||||||
"src/layers/firms/policy.js",
|
"src/layers/firms/policy.js",
|
||||||
@@ -475,7 +461,6 @@
|
|||||||
"src/layers/satellites/stale.test.mjs",
|
"src/layers/satellites/stale.test.mjs",
|
||||||
"src/layers/launches/source.test.mjs",
|
"src/layers/launches/source.test.mjs",
|
||||||
"src/layers/launches/ingestion.test.mjs",
|
"src/layers/launches/ingestion.test.mjs",
|
||||||
"src/layers/launches/failedEntry.test.mjs",
|
|
||||||
"src/layers/installations/lifecycle.js",
|
"src/layers/installations/lifecycle.js",
|
||||||
"src/layers/installations/viewport.js",
|
"src/layers/installations/viewport.js",
|
||||||
"src/layers/installations/controls.js",
|
"src/layers/installations/controls.js",
|
||||||
@@ -583,9 +568,7 @@
|
|||||||
"src/data/cctvHk.test.mjs",
|
"src/data/cctvHk.test.mjs",
|
||||||
"src/data/cctvVegvesen.test.mjs",
|
"src/data/cctvVegvesen.test.mjs",
|
||||||
"src/data/cctvA22.test.mjs",
|
"src/data/cctvA22.test.mjs",
|
||||||
"src/data/cctvIceland.test.mjs",
|
|
||||||
"src/data/cctvNyc.test.mjs",
|
"src/data/cctvNyc.test.mjs",
|
||||||
"src/data/cctvHouston.test.mjs",
|
|
||||||
"src/data/cctvNswSource.test.mjs",
|
"src/data/cctvNswSource.test.mjs",
|
||||||
"src/data/cctvTxdotSource.test.mjs",
|
"src/data/cctvTxdotSource.test.mjs",
|
||||||
"src/layers/cctv/source.test.mjs",
|
"src/layers/cctv/source.test.mjs",
|
||||||
@@ -698,7 +681,6 @@
|
|||||||
"src/app/layers/radio.js",
|
"src/app/layers/radio.js",
|
||||||
"src/app/layers/earthquakes.js",
|
"src/app/layers/earthquakes.js",
|
||||||
"src/app/layers/perimeters.js",
|
"src/app/layers/perimeters.js",
|
||||||
"src/app/layers/droneAirspace.js",
|
|
||||||
"src/app/layers/cctv.js",
|
"src/app/layers/cctv.js",
|
||||||
"src/app/layers/firms.js",
|
"src/app/layers/firms.js",
|
||||||
"src/app/layers/satellites.js",
|
"src/app/layers/satellites.js",
|
||||||
@@ -789,7 +771,6 @@
|
|||||||
"server/providers/transit.js",
|
"server/providers/transit.js",
|
||||||
"server/providers/transitHistory.js",
|
"server/providers/transitHistory.js",
|
||||||
"server/providers/transitHistory.test.mjs",
|
"server/providers/transitHistory.test.mjs",
|
||||||
"src/data/rioRouteNames.js",
|
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/transitFeeds.test.mjs",
|
"src/data/transitFeeds.test.mjs",
|
||||||
"src/data/transitProxy.js",
|
"src/data/transitProxy.js",
|
||||||
@@ -885,7 +866,6 @@
|
|||||||
"src/layers/weather/imageryOrder.test.mjs",
|
"src/layers/weather/imageryOrder.test.mjs",
|
||||||
"server/providers/cyclones.js",
|
"server/providers/cyclones.js",
|
||||||
"server/providers/firePerimeters.js",
|
"server/providers/firePerimeters.js",
|
||||||
"server/providers/droneAirspace.js",
|
|
||||||
"src/data/cyclonesProxy.test.mjs",
|
"src/data/cyclonesProxy.test.mjs",
|
||||||
"src/data/firePerimetersProxy.test.mjs",
|
"src/data/firePerimetersProxy.test.mjs",
|
||||||
"server/providers/gnssIntegrity.js",
|
"server/providers/gnssIntegrity.js",
|
||||||
@@ -910,7 +890,6 @@
|
|||||||
"src/layers/ghostMaps/records.test.mjs",
|
"src/layers/ghostMaps/records.test.mjs",
|
||||||
"src/layers/ghostMaps/lifecycle.test.mjs",
|
"src/layers/ghostMaps/lifecycle.test.mjs",
|
||||||
"scripts/qa-ghostmaps.mjs",
|
"scripts/qa-ghostmaps.mjs",
|
||||||
"src/data/droneAirspaceProxy.test.mjs",
|
|
||||||
"src/layers/cyclones/index.js",
|
"src/layers/cyclones/index.js",
|
||||||
"src/layers/cyclones/rendering.js",
|
"src/layers/cyclones/rendering.js",
|
||||||
"src/layers/cyclones/labels.js",
|
"src/layers/cyclones/labels.js",
|
||||||
@@ -1123,21 +1102,15 @@
|
|||||||
"scripts/qa-anime-sky.mjs",
|
"scripts/qa-anime-sky.mjs",
|
||||||
"scripts/qa-sky-model-lighting.mjs",
|
"scripts/qa-sky-model-lighting.mjs",
|
||||||
"server/providers/overpass/requestAdmission.test.mjs",
|
"server/providers/overpass/requestAdmission.test.mjs",
|
||||||
"src/tooling/trackArrivalObservation.mjs",
|
|
||||||
"src/tooling/trackArrivalObservation.test.mjs",
|
"src/tooling/trackArrivalObservation.test.mjs",
|
||||||
"src/ui/styles/status-badge.css",
|
"src/ui/styles/status-badge.css",
|
||||||
"src/ui/cctvBadges.test.mjs",
|
"src/ui/cctvBadges.test.mjs",
|
||||||
"scripts/qa-status-badges.mjs",
|
"scripts/qa-status-badges.mjs",
|
||||||
"src/dockerTrustedPeers.test.mjs",
|
"src/dockerTrustedPeers.test.mjs",
|
||||||
"src/cameraGlobeRotation.test.mjs",
|
"src/cameraGlobeRotation.test.mjs",
|
||||||
"src/cameraTilt.test.mjs",
|
|
||||||
"src/ui/shellFacade.test.mjs",
|
|
||||||
"src/voice/globeResetConfirmation.test.mjs",
|
"src/voice/globeResetConfirmation.test.mjs",
|
||||||
"src/hudDms.test.mjs",
|
"src/hudDms.test.mjs",
|
||||||
"src/hudSummaryGate.test.mjs",
|
"src/hudSummaryGate.test.mjs",
|
||||||
"src/hudSummaryLine.test.mjs",
|
|
||||||
"src/testSupport/stubMgrs.mjs",
|
|
||||||
"src/tools/queries/hud-caption-feed-state.test.mjs",
|
|
||||||
"src/voice/hudPlaceLabels.test.mjs",
|
"src/voice/hudPlaceLabels.test.mjs",
|
||||||
"src/voice/panel/preferences.test.mjs",
|
"src/voice/panel/preferences.test.mjs",
|
||||||
"src/voice/panel/preferencesView.test.mjs",
|
"src/voice/panel/preferencesView.test.mjs",
|
||||||
@@ -1204,9 +1177,6 @@
|
|||||||
"src/overlays/worldOverlayActionsView.test.mjs",
|
"src/overlays/worldOverlayActionsView.test.mjs",
|
||||||
"src/ui/stores/awarenessDirection.js",
|
"src/ui/stores/awarenessDirection.js",
|
||||||
"src/layers/awareness/directionOverlay.test.mjs",
|
"src/layers/awareness/directionOverlay.test.mjs",
|
||||||
"src/awarenessEcho.js",
|
|
||||||
"src/awarenessEcho.test.mjs",
|
|
||||||
"src/data/detectionAwarenessEcho.test.mjs",
|
|
||||||
"src/hudReadouts.js",
|
"src/hudReadouts.js",
|
||||||
"src/ui/stores/hud.js",
|
"src/ui/stores/hud.js",
|
||||||
"src/ui/views/intelHudView.test.mjs",
|
"src/ui/views/intelHudView.test.mjs",
|
||||||
@@ -1217,10 +1187,6 @@
|
|||||||
"src/data/headingDegrees.test.mjs",
|
"src/data/headingDegrees.test.mjs",
|
||||||
"src/data/distanceFormat.test.mjs",
|
"src/data/distanceFormat.test.mjs",
|
||||||
"src/sources/adsbFields.test.mjs",
|
"src/sources/adsbFields.test.mjs",
|
||||||
"src/data/firmsIntensity.js",
|
|
||||||
"src/data/firmsIntensity.test.mjs",
|
|
||||||
"src/ui/layerPanel/RowSwitch.vue",
|
|
||||||
"src/ui/layerPanel/RowSlider.vue",
|
|
||||||
"src/sources/phaseTiming.test.mjs",
|
"src/sources/phaseTiming.test.mjs",
|
||||||
"scripts/build-census-places.mjs",
|
"scripts/build-census-places.mjs",
|
||||||
"src/data/placeBoundaries.test.mjs",
|
"src/data/placeBoundaries.test.mjs",
|
||||||
@@ -1237,125 +1203,5 @@
|
|||||||
"src/standalone/layerSources.test.mjs",
|
"src/standalone/layerSources.test.mjs",
|
||||||
"src/tooling/nominatimGuardLifecycle.test.mjs",
|
"src/tooling/nominatimGuardLifecycle.test.mjs",
|
||||||
"src/tooling/nominatimLockOwnership.test.mjs",
|
"src/tooling/nominatimLockOwnership.test.mjs",
|
||||||
"src/tooling/nominatimOutlineRoute.test.mjs",
|
"src/tooling/nominatimOutlineRoute.test.mjs"
|
||||||
"server/providers/common/rateLimitKeys.test.mjs",
|
|
||||||
"src/voice/areaCandidateContract.js",
|
|
||||||
"src/voice/layerActionOwnership.js",
|
|
||||||
"src/data/areaGeometry.test.mjs",
|
|
||||||
"src/data/areaResolver.test.mjs",
|
|
||||||
"src/sources/osmFeatures.test.mjs",
|
|
||||||
"src/layers/osmPlaces/index.test.mjs",
|
|
||||||
"src/tooling/boundedCache.test.mjs",
|
|
||||||
"src/tooling/osmFeaturesRoute.test.mjs",
|
|
||||||
"src/voice/areaActions.test.mjs",
|
|
||||||
"src/voice/imageryActions.test.mjs",
|
|
||||||
"src/voice/osmActions.test.mjs",
|
|
||||||
"src/voice/realtimeProtocol.test.mjs",
|
|
||||||
"src/layers/awareness/rangeRing.test.mjs",
|
|
||||||
"src/voice/voiceMiniPreview.js",
|
|
||||||
"src/voice/voiceMiniPreview.test.mjs",
|
|
||||||
"src/voice/chatPreferences.test.mjs",
|
|
||||||
"src/voice/textChat.test.mjs",
|
|
||||||
"src/voice/chatAttachments.test.mjs",
|
|
||||||
"src/voice/chatControlView.test.mjs",
|
|
||||||
"src/textChatServer.test.mjs",
|
|
||||||
"src/geminiChatServer.test.mjs",
|
|
||||||
"src/voice/geminiTextChat.test.mjs",
|
|
||||||
"server/providers/common/textChat.js",
|
|
||||||
"server/providers/common/textInstructions.js",
|
|
||||||
"server/providers/common/textTools.js",
|
|
||||||
"server/providers/gemini/chat.js",
|
|
||||||
"server/providers/openai/chat.js",
|
|
||||||
"src/ui/styles/ai-chat.css",
|
|
||||||
"src/voice/chatAttachments.js",
|
|
||||||
"src/voice/chatControl.js",
|
|
||||||
"src/voice/chatPreferences.js",
|
|
||||||
"src/voice/geminiTextChat.js",
|
|
||||||
"src/voice/panelLayout.js",
|
|
||||||
"src/voice/textChat.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"
|
|
||||||
]
|
]
|
||||||
+42
-336
@@ -30,8 +30,6 @@
|
|||||||
"src/ui/views/panels/LocalePicker.vue",
|
"src/ui/views/panels/LocalePicker.vue",
|
||||||
"src/ui/views/panels/SceneDialog.vue",
|
"src/ui/views/panels/SceneDialog.vue",
|
||||||
"src/ui/views/panels/VoiceControl.vue",
|
"src/ui/views/panels/VoiceControl.vue",
|
||||||
"src/ui/views/panels/ChatPanel.vue",
|
|
||||||
"src/ui/views/panels/PanelLayoutButtons.vue",
|
|
||||||
"src/ui/views/panels/VoiceCard.vue",
|
"src/ui/views/panels/VoiceCard.vue",
|
||||||
"src/ui/views/panels/VoiceVisualizer.vue",
|
"src/ui/views/panels/VoiceVisualizer.vue",
|
||||||
"src/ui/localePicker.js",
|
"src/ui/localePicker.js",
|
||||||
@@ -39,9 +37,6 @@
|
|||||||
"src/ui/views/panels/CockpitBriefing.vue",
|
"src/ui/views/panels/CockpitBriefing.vue",
|
||||||
"src/ui/views/panels/CockpitContext.vue",
|
"src/ui/views/panels/CockpitContext.vue",
|
||||||
"src/ui/views/panels/CockpitRoute.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/CockpitRollingValue.vue",
|
||||||
"src/ui/views/panels/CockpitSignalList.vue",
|
"src/ui/views/panels/CockpitSignalList.vue",
|
||||||
"src/ui/views/panels/CockpitUtilityControls.vue",
|
"src/ui/views/panels/CockpitUtilityControls.vue",
|
||||||
@@ -155,16 +150,23 @@
|
|||||||
"src/ui/stores/recording.js",
|
"src/ui/stores/recording.js",
|
||||||
"src/ui/styleParameters.js",
|
"src/ui/styleParameters.js",
|
||||||
"src/ui/i18n.js",
|
"src/ui/i18n.js",
|
||||||
"src/ui/surfaceKeyboard.js"
|
"src/time/worldClock.js",
|
||||||
|
"src/ui/kit/UiIcon.vue",
|
||||||
|
"src/ui/stores/worldTime.js",
|
||||||
|
"src/ui/views/panels/WorldTimeline.vue"
|
||||||
],
|
],
|
||||||
"external": ["vue", "pinia", "vue-i18n"]
|
"external": ["vue", "pinia", "vue-i18n"]
|
||||||
},
|
},
|
||||||
|
"time": {
|
||||||
|
"exports": ["./time"],
|
||||||
|
"modules": ["src/time/worldClock.js"],
|
||||||
|
"external": []
|
||||||
|
},
|
||||||
"viewer": {
|
"viewer": {
|
||||||
"exports": ["./application/viewer"],
|
"exports": ["./application/viewer"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/app/viewer.js",
|
"src/app/viewer.js",
|
||||||
"src/app/atmosphereCompat.js",
|
"src/app/atmosphereCompat.js",
|
||||||
"src/globeInView.js",
|
|
||||||
"src/sky/starrySky.js",
|
"src/sky/starrySky.js",
|
||||||
"src/sky/stars.js",
|
"src/sky/stars.js",
|
||||||
"src/data/bundledJson.js",
|
"src/data/bundledJson.js",
|
||||||
@@ -204,22 +206,7 @@
|
|||||||
"src/data/aisStreamAdapter.js",
|
"src/data/aisStreamAdapter.js",
|
||||||
"src/data/aisWatchdog.js",
|
"src/data/aisWatchdog.js",
|
||||||
"src/sources/httpBody.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"]
|
"external": ["ws"]
|
||||||
},
|
},
|
||||||
@@ -363,9 +350,6 @@
|
|||||||
"server/providers/cctv/catalog.js",
|
"server/providers/cctv/catalog.js",
|
||||||
"server/providers/cctv/lithuania.js",
|
"server/providers/cctv/lithuania.js",
|
||||||
"server/providers/cctv/taiwan.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/constants.js",
|
||||||
"server/providers/cctv/media.js",
|
"server/providers/cctv/media.js",
|
||||||
"server/providers/cctv/normalize.js",
|
"server/providers/cctv/normalize.js",
|
||||||
@@ -441,11 +425,7 @@
|
|||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/sources/overpass.js",
|
"src/sources/overpass.js",
|
||||||
"src/sources/rateLimit.js",
|
"src/sources/rateLimit.js"
|
||||||
"server/providers/common/boundedCache.js",
|
|
||||||
"src/data/osmPresets.js",
|
|
||||||
"server/providers/overpass/osmFeatures.js",
|
|
||||||
"server/providers/overpass/osmFeaturesRoute.js"
|
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -514,20 +494,10 @@
|
|||||||
"exports": ["./server/providers/openai"],
|
"exports": ["./server/providers/openai"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/i18n/locale.js",
|
|
||||||
"src/hudLayoutPolicy.js",
|
"src/hudLayoutPolicy.js",
|
||||||
"server/providers/common/rate-limit.js",
|
"server/providers/common/rate-limit.js",
|
||||||
"server/providers/common/request.js",
|
"server/providers/common/request.js",
|
||||||
"server/providers/common/source-root.js",
|
"server/providers/common/source-root.js",
|
||||||
"server/providers/common/textChat.js",
|
|
||||||
"server/providers/common/textInstructions.js",
|
|
||||||
"server/providers/common/textTools.js",
|
|
||||||
"server/providers/openai/chat.js",
|
|
||||||
"server/providers/gemini/request.js",
|
|
||||||
"build/allowedHosts.js",
|
|
||||||
"src/tools/schema.js",
|
|
||||||
"src/sources/requestDeadline.js",
|
|
||||||
"src/sources/httpBody.js",
|
|
||||||
"server/providers/openai.js",
|
"server/providers/openai.js",
|
||||||
"server/providers/openai/codex-auth.js",
|
"server/providers/openai/codex-auth.js",
|
||||||
"server/providers/openai/constants.js",
|
"server/providers/openai/constants.js",
|
||||||
@@ -546,11 +516,9 @@
|
|||||||
"server/providers/common/same-site.js",
|
"server/providers/common/same-site.js",
|
||||||
"src/localRequestGate.mjs",
|
"src/localRequestGate.mjs",
|
||||||
"src/voice/actionSchemas.js",
|
"src/voice/actionSchemas.js",
|
||||||
"src/voice/areaCandidateContract.js",
|
|
||||||
"src/voice/layerManifest.js",
|
"src/voice/layerManifest.js",
|
||||||
"server/providers/openai/toolDescriptions.js",
|
"server/providers/openai/toolDescriptions.js",
|
||||||
"src/sources/rateLimit.js",
|
"src/sources/rateLimit.js"
|
||||||
"server/providers/overpass/constants.js"
|
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -575,7 +543,6 @@
|
|||||||
"src/search/placeSearch.js",
|
"src/search/placeSearch.js",
|
||||||
"src/search/google.js",
|
"src/search/google.js",
|
||||||
"src/keylessGeocoder.js",
|
"src/keylessGeocoder.js",
|
||||||
"src/data/placeNames.js",
|
|
||||||
"src/search/geospatial.js",
|
"src/search/geospatial.js",
|
||||||
"src/search/http.js",
|
"src/search/http.js",
|
||||||
"src/search/defaults.js",
|
"src/search/defaults.js",
|
||||||
@@ -709,8 +676,6 @@
|
|||||||
"src/ui/rail/classToggle.js",
|
"src/ui/rail/classToggle.js",
|
||||||
"src/ui/rail/focusSafeChildren.js",
|
"src/ui/rail/focusSafeChildren.js",
|
||||||
"src/ui/layerPanel/RowText.vue",
|
"src/ui/layerPanel/RowText.vue",
|
||||||
"src/ui/layerPanel/RowSwitch.vue",
|
|
||||||
"src/ui/layerPanel/RowSlider.vue",
|
|
||||||
"src/ui/railTimeline.js",
|
"src/ui/railTimeline.js",
|
||||||
"src/ui/clearLayersControl.js",
|
"src/ui/clearLayersControl.js",
|
||||||
"src/loadingFeedback.js",
|
"src/loadingFeedback.js",
|
||||||
@@ -748,8 +713,7 @@
|
|||||||
"src/ui/kit/UiButton.vue",
|
"src/ui/kit/UiButton.vue",
|
||||||
"src/ui/kit/UiBadge.vue",
|
"src/ui/kit/UiBadge.vue",
|
||||||
"src/ui/rail/fieldValue.js",
|
"src/ui/rail/fieldValue.js",
|
||||||
"src/ui/kit/UiSlider.js",
|
"src/ui/kit/UiSlider.js"
|
||||||
"src/ui/kit/UiSegmented.vue"
|
|
||||||
],
|
],
|
||||||
"external": ["vue"]
|
"external": ["vue"]
|
||||||
},
|
},
|
||||||
@@ -809,7 +773,6 @@
|
|||||||
"cctv-controls": {
|
"cctv-controls": {
|
||||||
"exports": ["./ui/cctv"],
|
"exports": ["./ui/cctv"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/iconOrientation.js",
|
|
||||||
"src/data/headingDegrees.js",
|
"src/data/headingDegrees.js",
|
||||||
"src/ui/cctv.js",
|
"src/ui/cctv.js",
|
||||||
"src/ui/cctvBindings.js",
|
"src/ui/cctvBindings.js",
|
||||||
@@ -818,7 +781,6 @@
|
|||||||
"src/ui/cctvFrames.js",
|
"src/ui/cctvFrames.js",
|
||||||
"src/ui/cctvVideo.js",
|
"src/ui/cctvVideo.js",
|
||||||
"src/ui/cctvPresentation.js",
|
"src/ui/cctvPresentation.js",
|
||||||
"src/ui/quebecCctvOverlay.js",
|
|
||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/ar.json",
|
"src/i18n/locales/ar.json",
|
||||||
@@ -838,7 +800,7 @@
|
|||||||
"src/ui/stores/status.js",
|
"src/ui/stores/status.js",
|
||||||
"src/ui/i18n.js"
|
"src/ui/i18n.js"
|
||||||
],
|
],
|
||||||
"external": ["vue", "pinia", "vue-i18n", "cesium"]
|
"external": ["vue", "pinia", "vue-i18n"]
|
||||||
},
|
},
|
||||||
"context-controls": {
|
"context-controls": {
|
||||||
"exports": ["./ui/context", "./ui/context/policy"],
|
"exports": ["./ui/context", "./ui/context/policy"],
|
||||||
@@ -895,7 +857,6 @@
|
|||||||
"src/ui/cockpitDisplayPortal.js",
|
"src/ui/cockpitDisplayPortal.js",
|
||||||
"src/ui/displayPanelScroll.js",
|
"src/ui/displayPanelScroll.js",
|
||||||
"src/ui/cockpitInput.js",
|
"src/ui/cockpitInput.js",
|
||||||
"src/data/inputOwnership.js",
|
|
||||||
"src/ui/cockpitInstruments.js",
|
"src/ui/cockpitInstruments.js",
|
||||||
"src/ui/cockpitLayout.js",
|
"src/ui/cockpitLayout.js",
|
||||||
"src/ui/cockpitPresentation.js",
|
"src/ui/cockpitPresentation.js",
|
||||||
@@ -918,7 +879,6 @@
|
|||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json",
|
"src/i18n/locales/zh-Hant.json",
|
||||||
"src/ui/layerNames.js",
|
|
||||||
"src/ui/unitLabels.js"
|
"src/ui/unitLabels.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
@@ -977,7 +937,6 @@
|
|||||||
"src/data/detectionPolicy.js",
|
"src/data/detectionPolicy.js",
|
||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
"src/data/idSwitches.js",
|
"src/data/idSwitches.js",
|
||||||
"src/data/firmsIntensity.js",
|
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateCoordinator.js",
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
@@ -1011,8 +970,6 @@
|
|||||||
"src/ui/cctvFrames.js",
|
"src/ui/cctvFrames.js",
|
||||||
"src/ui/cctvVideo.js",
|
"src/ui/cctvVideo.js",
|
||||||
"src/ui/cctvPresentation.js",
|
"src/ui/cctvPresentation.js",
|
||||||
"src/data/iconOrientation.js",
|
|
||||||
"src/ui/quebecCctvOverlay.js",
|
|
||||||
"src/ui/clearLayersControl.js",
|
"src/ui/clearLayersControl.js",
|
||||||
"src/ui/cockpit.js",
|
"src/ui/cockpit.js",
|
||||||
"src/ui/cockpitBriefing.js",
|
"src/ui/cockpitBriefing.js",
|
||||||
@@ -1026,7 +983,6 @@
|
|||||||
"src/ui/cockpitDisplayPortal.js",
|
"src/ui/cockpitDisplayPortal.js",
|
||||||
"src/ui/displayPanelScroll.js",
|
"src/ui/displayPanelScroll.js",
|
||||||
"src/ui/cockpitInput.js",
|
"src/ui/cockpitInput.js",
|
||||||
"src/data/inputOwnership.js",
|
|
||||||
"src/ui/cockpitInstruments.js",
|
"src/ui/cockpitInstruments.js",
|
||||||
"src/ui/cockpitLayout.js",
|
"src/ui/cockpitLayout.js",
|
||||||
"src/ui/cockpitPresentation.js",
|
"src/ui/cockpitPresentation.js",
|
||||||
@@ -1053,8 +1009,6 @@
|
|||||||
"src/ui/rail/classToggle.js",
|
"src/ui/rail/classToggle.js",
|
||||||
"src/ui/rail/focusSafeChildren.js",
|
"src/ui/rail/focusSafeChildren.js",
|
||||||
"src/ui/layerPanel/RowText.vue",
|
"src/ui/layerPanel/RowText.vue",
|
||||||
"src/ui/layerPanel/RowSwitch.vue",
|
|
||||||
"src/ui/layerPanel/RowSlider.vue",
|
|
||||||
"src/ui/railTimeline.js",
|
"src/ui/railTimeline.js",
|
||||||
"src/ui/layers.js",
|
"src/ui/layers.js",
|
||||||
"src/ui/leftPanelRail.js",
|
"src/ui/leftPanelRail.js",
|
||||||
@@ -1179,14 +1133,7 @@
|
|||||||
"src/ui/stores/globeActions.js",
|
"src/ui/stores/globeActions.js",
|
||||||
"src/ui/stores/recording.js",
|
"src/ui/stores/recording.js",
|
||||||
"src/ui/i18n.js",
|
"src/ui/i18n.js",
|
||||||
"src/ui/kit/UiSegmented.vue",
|
"src/time/worldClock.js"
|
||||||
"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"]
|
"external": ["cesium", "vue", "pinia", "vue-i18n"]
|
||||||
},
|
},
|
||||||
@@ -1204,7 +1151,6 @@
|
|||||||
"src/ui/styles/layers.css",
|
"src/ui/styles/layers.css",
|
||||||
"src/ui/styles/radio.css",
|
"src/ui/styles/radio.css",
|
||||||
"src/ui/styles/cctv.css",
|
"src/ui/styles/cctv.css",
|
||||||
"src/ui/styles/quebecCctvOverlay.css",
|
|
||||||
"src/ui/styles/scenes.css",
|
"src/ui/styles/scenes.css",
|
||||||
"src/ui/styles/bhote-koshi.css",
|
"src/ui/styles/bhote-koshi.css",
|
||||||
"src/ui/styles/recording.css",
|
"src/ui/styles/recording.css",
|
||||||
@@ -1356,7 +1302,6 @@
|
|||||||
"src/data/contactMatch.js",
|
"src/data/contactMatch.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
"src/data/idSwitches.js",
|
"src/data/idSwitches.js",
|
||||||
"src/data/firmsIntensity.js",
|
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/modelVisualAnchor.js",
|
"src/data/modelVisualAnchor.js",
|
||||||
@@ -1399,7 +1344,9 @@
|
|||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json",
|
"src/i18n/locales/zh-Hant.json",
|
||||||
"src/ui/unitLabels.js"
|
"src/ui/unitLabels.js",
|
||||||
|
"src/time/worldClock.js",
|
||||||
|
"src/time/julian.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1419,7 +1366,6 @@
|
|||||||
"src/data/contactMatch.js",
|
"src/data/contactMatch.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
"src/data/idSwitches.js",
|
"src/data/idSwitches.js",
|
||||||
"src/data/firmsIntensity.js",
|
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/modelVisualAnchor.js",
|
"src/data/modelVisualAnchor.js",
|
||||||
@@ -1456,7 +1402,9 @@
|
|||||||
"src/i18n/locales/ru.json",
|
"src/i18n/locales/ru.json",
|
||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json"
|
"src/i18n/locales/zh-Hant.json",
|
||||||
|
"src/time/worldClock.js",
|
||||||
|
"src/time/julian.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1473,7 +1421,6 @@
|
|||||||
"src/data/contactMatch.js",
|
"src/data/contactMatch.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
"src/data/idSwitches.js",
|
"src/data/idSwitches.js",
|
||||||
"src/data/firmsIntensity.js",
|
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateCoordinator.js",
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
@@ -1575,8 +1522,6 @@
|
|||||||
"exports": ["./layers/firms"],
|
"exports": ["./layers/firms"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/firmsAdapt.js",
|
"src/data/firmsAdapt.js",
|
||||||
"src/data/firmsIntensity.js",
|
|
||||||
"src/data/firmsHeatRaster.js",
|
|
||||||
"src/data/firmsLabels.js",
|
"src/data/firmsLabels.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
"src/layers/firms/anchors.js",
|
"src/layers/firms/anchors.js",
|
||||||
@@ -1588,7 +1533,6 @@
|
|||||||
"src/layers/firms/policy.js",
|
"src/layers/firms/policy.js",
|
||||||
"src/layers/firms/queries.js",
|
"src/layers/firms/queries.js",
|
||||||
"src/layers/firms/rendering.js",
|
"src/layers/firms/rendering.js",
|
||||||
"src/layers/firms/heatImagery.js",
|
|
||||||
"src/layers/firms/selection.js",
|
"src/layers/firms/selection.js",
|
||||||
"src/layers/firms/source.js",
|
"src/layers/firms/source.js",
|
||||||
"src/layers/firms/state.js",
|
"src/layers/firms/state.js",
|
||||||
@@ -1645,7 +1589,8 @@
|
|||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json",
|
"src/i18n/locales/zh-Hant.json",
|
||||||
"src/ui/unitLabels.js"
|
"src/ui/unitLabels.js",
|
||||||
|
"src/time/worldClock.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "satellite.js"]
|
"external": ["cesium", "satellite.js"]
|
||||||
},
|
},
|
||||||
@@ -1685,7 +1630,8 @@
|
|||||||
"src/i18n/locales/ru.json",
|
"src/i18n/locales/ru.json",
|
||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json"
|
"src/i18n/locales/zh-Hant.json",
|
||||||
|
"src/time/worldClock.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1747,7 +1693,6 @@
|
|||||||
"src/data/distanceFormat.js",
|
"src/data/distanceFormat.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/cyberSonar.js",
|
"src/cyberSonar.js",
|
||||||
"src/awarenessEcho.js",
|
|
||||||
"src/cockpitMath.js",
|
"src/cockpitMath.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
"src/data/militaryAwarenessEngine.js",
|
"src/data/militaryAwarenessEngine.js",
|
||||||
@@ -1783,10 +1728,6 @@
|
|||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.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"
|
"src/ui/unitLabels.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
@@ -2010,7 +1951,8 @@
|
|||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json",
|
"src/i18n/locales/zh-Hant.json",
|
||||||
"src/layers/streetLevel/providers/mapillary/policy.js"
|
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||||
|
"src/time/worldClock.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
||||||
},
|
},
|
||||||
@@ -2056,7 +1998,6 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
"src/data/idSwitches.js",
|
"src/data/idSwitches.js",
|
||||||
"src/data/firmsIntensity.js",
|
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/radioCountry.js",
|
"src/data/radioCountry.js",
|
||||||
@@ -2208,14 +2149,6 @@
|
|||||||
"src/voice/realtimeBackend.js",
|
"src/voice/realtimeBackend.js",
|
||||||
"src/voice/cloudVoiceAuth.js",
|
"src/voice/cloudVoiceAuth.js",
|
||||||
"src/voice/control.js",
|
"src/voice/control.js",
|
||||||
"src/voice/chatControl.js",
|
|
||||||
"src/voice/geminiTextChat.js",
|
|
||||||
"src/voice/textChat.js",
|
|
||||||
"src/voice/textProtocol.js",
|
|
||||||
"src/voice/chatAttachments.js",
|
|
||||||
"src/voice/chatPreferences.js",
|
|
||||||
"src/voice/panelLayout.js",
|
|
||||||
"src/voice/voiceMiniPreview.js",
|
|
||||||
"src/voice/layerSummary.js",
|
"src/voice/layerSummary.js",
|
||||||
"src/voice/voiceCost.js",
|
"src/voice/voiceCost.js",
|
||||||
"src/voice/commands.js",
|
"src/voice/commands.js",
|
||||||
@@ -2311,7 +2244,6 @@
|
|||||||
"src/ui/placeNames.js",
|
"src/ui/placeNames.js",
|
||||||
"src/cyberSonarScene.js",
|
"src/cyberSonarScene.js",
|
||||||
"src/cyberSonar.js",
|
"src/cyberSonar.js",
|
||||||
"src/awarenessEcho.js",
|
|
||||||
"src/hudLayouts.js",
|
"src/hudLayouts.js",
|
||||||
"src/hudLayoutPolicy.js",
|
"src/hudLayoutPolicy.js",
|
||||||
"src/ui/radioPanelPlacement.js",
|
"src/ui/radioPanelPlacement.js",
|
||||||
@@ -2344,7 +2276,6 @@
|
|||||||
"src/app/layers/alprCameras.js",
|
"src/app/layers/alprCameras.js",
|
||||||
"src/app/layers/bikeshare.js",
|
"src/app/layers/bikeshare.js",
|
||||||
"src/app/layers/cctv.js",
|
"src/app/layers/cctv.js",
|
||||||
"src/app/layers/cyclones.js",
|
|
||||||
"src/app/layers/earthquakes.js",
|
"src/app/layers/earthquakes.js",
|
||||||
"src/app/layers/firms.js",
|
"src/app/layers/firms.js",
|
||||||
"src/app/layers/flights.js",
|
"src/app/layers/flights.js",
|
||||||
@@ -2359,7 +2290,6 @@
|
|||||||
"src/app/layers/traffic.js",
|
"src/app/layers/traffic.js",
|
||||||
"src/app/operations.js",
|
"src/app/operations.js",
|
||||||
"src/app/scene.js",
|
"src/app/scene.js",
|
||||||
"src/app/zoomOutGuard.js",
|
|
||||||
"src/sources/sourceSlot.js",
|
"src/sources/sourceSlot.js",
|
||||||
"src/app/sources.js",
|
"src/app/sources.js",
|
||||||
"src/app/startupChrome.js",
|
"src/app/startupChrome.js",
|
||||||
@@ -2374,7 +2304,6 @@
|
|||||||
"src/camera.js",
|
"src/camera.js",
|
||||||
"src/cameraGroundGuard.js",
|
"src/cameraGroundGuard.js",
|
||||||
"src/cameraVerbs.js",
|
"src/cameraVerbs.js",
|
||||||
"src/globeInView.js",
|
|
||||||
"src/cctvFocusPolicy.js",
|
"src/cctvFocusPolicy.js",
|
||||||
"src/cctvFocusRequest.js",
|
"src/cctvFocusRequest.js",
|
||||||
"src/celestialRing.js",
|
"src/celestialRing.js",
|
||||||
@@ -2431,8 +2360,6 @@
|
|||||||
"src/data/fireAnchors.js",
|
"src/data/fireAnchors.js",
|
||||||
"src/data/firmsAdapt.js",
|
"src/data/firmsAdapt.js",
|
||||||
"src/data/firmsHeatmap.js",
|
"src/data/firmsHeatmap.js",
|
||||||
"src/data/firmsIntensity.js",
|
|
||||||
"src/data/firmsHeatRaster.js",
|
|
||||||
"src/data/firmsLabels.js",
|
"src/data/firmsLabels.js",
|
||||||
"src/data/flights.js",
|
"src/data/flights.js",
|
||||||
"src/data/flowMatch.js",
|
"src/data/flowMatch.js",
|
||||||
@@ -2442,7 +2369,6 @@
|
|||||||
"src/data/groundSnap.js",
|
"src/data/groundSnap.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
"src/data/infrastructure.js",
|
"src/data/infrastructure.js",
|
||||||
"src/data/osmOverlays.js",
|
|
||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
"src/data/labelArbiter.js",
|
"src/data/labelArbiter.js",
|
||||||
"src/data/idSwitches.js",
|
"src/data/idSwitches.js",
|
||||||
@@ -2594,7 +2520,6 @@
|
|||||||
"src/layers/firms/policy.js",
|
"src/layers/firms/policy.js",
|
||||||
"src/layers/firms/queries.js",
|
"src/layers/firms/queries.js",
|
||||||
"src/layers/firms/rendering.js",
|
"src/layers/firms/rendering.js",
|
||||||
"src/layers/firms/heatImagery.js",
|
|
||||||
"src/layers/firms/selection.js",
|
"src/layers/firms/selection.js",
|
||||||
"src/layers/firms/source.js",
|
"src/layers/firms/source.js",
|
||||||
"src/layers/firms/state.js",
|
"src/layers/firms/state.js",
|
||||||
@@ -2816,7 +2741,6 @@
|
|||||||
"src/ui/cctvFrames.js",
|
"src/ui/cctvFrames.js",
|
||||||
"src/ui/cctvVideo.js",
|
"src/ui/cctvVideo.js",
|
||||||
"src/ui/cctvPresentation.js",
|
"src/ui/cctvPresentation.js",
|
||||||
"src/ui/quebecCctvOverlay.js",
|
|
||||||
"src/ui/clearLayersControl.js",
|
"src/ui/clearLayersControl.js",
|
||||||
"src/ui/cockpit.js",
|
"src/ui/cockpit.js",
|
||||||
"src/ui/cockpitBriefing.js",
|
"src/ui/cockpitBriefing.js",
|
||||||
@@ -2857,8 +2781,6 @@
|
|||||||
"src/ui/rail/classToggle.js",
|
"src/ui/rail/classToggle.js",
|
||||||
"src/ui/rail/focusSafeChildren.js",
|
"src/ui/rail/focusSafeChildren.js",
|
||||||
"src/ui/layerPanel/RowText.vue",
|
"src/ui/layerPanel/RowText.vue",
|
||||||
"src/ui/layerPanel/RowSwitch.vue",
|
|
||||||
"src/ui/layerPanel/RowSlider.vue",
|
|
||||||
"src/ui/railTimeline.js",
|
"src/ui/railTimeline.js",
|
||||||
"src/ui/layers.js",
|
"src/ui/layers.js",
|
||||||
"src/ui/leftPanelRail.js",
|
"src/ui/leftPanelRail.js",
|
||||||
@@ -2907,14 +2829,6 @@
|
|||||||
"src/voice/panel/nativeActivity.js",
|
"src/voice/panel/nativeActivity.js",
|
||||||
"src/voice/panel/preferences.js",
|
"src/voice/panel/preferences.js",
|
||||||
"src/voice/control.js",
|
"src/voice/control.js",
|
||||||
"src/voice/chatControl.js",
|
|
||||||
"src/voice/geminiTextChat.js",
|
|
||||||
"src/voice/textChat.js",
|
|
||||||
"src/voice/textProtocol.js",
|
|
||||||
"src/voice/chatAttachments.js",
|
|
||||||
"src/voice/chatPreferences.js",
|
|
||||||
"src/voice/panelLayout.js",
|
|
||||||
"src/voice/voiceMiniPreview.js",
|
|
||||||
"src/voice/gevActions.js",
|
"src/voice/gevActions.js",
|
||||||
"src/agent/worldPacket.js",
|
"src/agent/worldPacket.js",
|
||||||
"src/voice/speech.js",
|
"src/voice/speech.js",
|
||||||
@@ -2985,11 +2899,6 @@
|
|||||||
"src/layers/ghostMaps/records.js",
|
"src/layers/ghostMaps/records.js",
|
||||||
"src/layers/ghostMaps/kml.js",
|
"src/layers/ghostMaps/kml.js",
|
||||||
"src/layers/ghostMaps/source.js",
|
"src/layers/ghostMaps/source.js",
|
||||||
"src/app/layers/droneAirspace.js",
|
|
||||||
"src/layers/droneAirspace/index.js",
|
|
||||||
"src/layers/droneAirspace/source.js",
|
|
||||||
"src/layers/droneAirspace/records.js",
|
|
||||||
"src/layers/droneAirspace/cards.js",
|
|
||||||
"src/layers/cctv/sourcePolicy.js",
|
"src/layers/cctv/sourcePolicy.js",
|
||||||
"src/sources/reference.js",
|
"src/sources/reference.js",
|
||||||
"src/search/nominatim.js",
|
"src/search/nominatim.js",
|
||||||
@@ -3000,7 +2909,6 @@
|
|||||||
"src/app/layers/transit.js",
|
"src/app/layers/transit.js",
|
||||||
"src/data/transit.js",
|
"src/data/transit.js",
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.js",
|
|
||||||
"src/data/transitIcons.js",
|
"src/data/transitIcons.js",
|
||||||
"src/data/transitPresetStyle.js",
|
"src/data/transitPresetStyle.js",
|
||||||
"src/layers/transit/controls.js",
|
"src/layers/transit/controls.js",
|
||||||
@@ -3284,6 +3192,9 @@
|
|||||||
"src/ui/stores/globeActions.js",
|
"src/ui/stores/globeActions.js",
|
||||||
"src/ui/stores/recording.js",
|
"src/ui/stores/recording.js",
|
||||||
"src/ui/i18n.js",
|
"src/ui/i18n.js",
|
||||||
|
"src/time/viewerClock.js",
|
||||||
|
"src/time/worldClock.js",
|
||||||
|
"src/time/julian.js",
|
||||||
"src/annotations/outlineRungs.js",
|
"src/annotations/outlineRungs.js",
|
||||||
"src/sources/openFreeMapOutlines.js",
|
"src/sources/openFreeMapOutlines.js",
|
||||||
"src/sources/nominatimOutlines.js",
|
"src/sources/nominatimOutlines.js",
|
||||||
@@ -3291,45 +3202,7 @@
|
|||||||
"src/data/placeBoundaries.js",
|
"src/data/placeBoundaries.js",
|
||||||
"src/data/placeNames.js",
|
"src/data/placeNames.js",
|
||||||
"src/data/local_data/us_census_places/files.js",
|
"src/data/local_data/us_census_places/files.js",
|
||||||
"src/data/local_data/wof_neighborhoods/files.js",
|
"src/data/local_data/wof_neighborhoods/files.js"
|
||||||
"src/sources/osmFeatures.js",
|
|
||||||
"src/voice/imageryActions.js",
|
|
||||||
"src/voice/layerActionOwnership.js",
|
|
||||||
"src/data/areaGeometry.js",
|
|
||||||
"src/layers/osmPlaces/model.js",
|
|
||||||
"src/data/areaStore.js",
|
|
||||||
"src/data/areaResolver.js",
|
|
||||||
"src/voice/areaCandidateContract.js",
|
|
||||||
"src/data/osmPresets.js",
|
|
||||||
"src/voice/osmActions.js",
|
|
||||||
"src/layers/osmPlaces/index.js",
|
|
||||||
"src/app/layers/osmPlaces.js",
|
|
||||||
"src/voice/areaActions.js",
|
|
||||||
"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": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -3354,13 +3227,10 @@
|
|||||||
"application-layer-construction": {
|
"application-layer-construction": {
|
||||||
"exports": ["./application/layers"],
|
"exports": ["./application/layers"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/feedState.js",
|
|
||||||
"src/loadingFeedback.js",
|
|
||||||
"src/sources/adsbFields.js",
|
"src/sources/adsbFields.js",
|
||||||
"src/data/headingDegrees.js",
|
"src/data/headingDegrees.js",
|
||||||
"src/data/distanceFormat.js",
|
"src/data/distanceFormat.js",
|
||||||
"src/cyberSonar.js",
|
"src/cyberSonar.js",
|
||||||
"src/awarenessEcho.js",
|
|
||||||
"src/data/approxDistance.js",
|
"src/data/approxDistance.js",
|
||||||
"src/data/bhoteKoshiEvent.js",
|
"src/data/bhoteKoshiEvent.js",
|
||||||
"src/ui/imagerySplit.js",
|
"src/ui/imagerySplit.js",
|
||||||
@@ -3390,7 +3260,6 @@
|
|||||||
"src/app/layers/alprCameras.js",
|
"src/app/layers/alprCameras.js",
|
||||||
"src/app/layers/bikeshare.js",
|
"src/app/layers/bikeshare.js",
|
||||||
"src/app/layers/cctv.js",
|
"src/app/layers/cctv.js",
|
||||||
"src/app/layers/cyclones.js",
|
|
||||||
"src/app/layers/earthquakes.js",
|
"src/app/layers/earthquakes.js",
|
||||||
"src/app/layers/firms.js",
|
"src/app/layers/firms.js",
|
||||||
"src/app/layers/flights.js",
|
"src/app/layers/flights.js",
|
||||||
@@ -3427,15 +3296,12 @@
|
|||||||
"src/data/detectionDraw.js",
|
"src/data/detectionDraw.js",
|
||||||
"src/data/detectionPolicy.js",
|
"src/data/detectionPolicy.js",
|
||||||
"src/data/firmsAdapt.js",
|
"src/data/firmsAdapt.js",
|
||||||
"src/data/firmsIntensity.js",
|
|
||||||
"src/data/firmsHeatRaster.js",
|
|
||||||
"src/data/firmsLabels.js",
|
"src/data/firmsLabels.js",
|
||||||
"src/data/flowMatch.js",
|
"src/data/flowMatch.js",
|
||||||
"src/data/focusDeemphasis.js",
|
"src/data/focusDeemphasis.js",
|
||||||
"src/data/geoid.js",
|
"src/data/geoid.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
"src/data/infrastructure.js",
|
"src/data/infrastructure.js",
|
||||||
"src/data/osmOverlays.js",
|
|
||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
"src/data/satellitePass.js",
|
"src/data/satellitePass.js",
|
||||||
"src/data/labelArbiter.js",
|
"src/data/labelArbiter.js",
|
||||||
@@ -3560,7 +3426,6 @@
|
|||||||
"src/layers/firms/policy.js",
|
"src/layers/firms/policy.js",
|
||||||
"src/layers/firms/queries.js",
|
"src/layers/firms/queries.js",
|
||||||
"src/layers/firms/rendering.js",
|
"src/layers/firms/rendering.js",
|
||||||
"src/layers/firms/heatImagery.js",
|
|
||||||
"src/layers/firms/selection.js",
|
"src/layers/firms/selection.js",
|
||||||
"src/layers/firms/source.js",
|
"src/layers/firms/source.js",
|
||||||
"src/layers/firms/state.js",
|
"src/layers/firms/state.js",
|
||||||
@@ -3733,9 +3598,6 @@
|
|||||||
"src/annotations/worldAnnotationRenderer.js",
|
"src/annotations/worldAnnotationRenderer.js",
|
||||||
"src/cameraGroundGuard.js",
|
"src/cameraGroundGuard.js",
|
||||||
"src/cameraVerbs.js",
|
"src/cameraVerbs.js",
|
||||||
"src/globeInView.js",
|
|
||||||
"src/scopeMaskPolicy.js",
|
|
||||||
"src/scopeMask.js",
|
|
||||||
"src/services/groundFloor.js",
|
"src/services/groundFloor.js",
|
||||||
"src/data/routeSteps.js",
|
"src/data/routeSteps.js",
|
||||||
"src/layers/directions/index.js",
|
"src/layers/directions/index.js",
|
||||||
@@ -3768,11 +3630,6 @@
|
|||||||
"src/layers/ghostMaps/records.js",
|
"src/layers/ghostMaps/records.js",
|
||||||
"src/layers/ghostMaps/kml.js",
|
"src/layers/ghostMaps/kml.js",
|
||||||
"src/layers/ghostMaps/source.js",
|
"src/layers/ghostMaps/source.js",
|
||||||
"src/app/layers/droneAirspace.js",
|
|
||||||
"src/layers/droneAirspace/index.js",
|
|
||||||
"src/layers/droneAirspace/source.js",
|
|
||||||
"src/layers/droneAirspace/records.js",
|
|
||||||
"src/layers/droneAirspace/cards.js",
|
|
||||||
"src/layers/cctv/sourcePolicy.js",
|
"src/layers/cctv/sourcePolicy.js",
|
||||||
"src/search/nominatim.js",
|
"src/search/nominatim.js",
|
||||||
"src/nominatimGeocode.js",
|
"src/nominatimGeocode.js",
|
||||||
@@ -3782,7 +3639,6 @@
|
|||||||
"src/sources/nominatim.js",
|
"src/sources/nominatim.js",
|
||||||
"src/app/layers/transit.js",
|
"src/app/layers/transit.js",
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.js",
|
|
||||||
"src/data/transitIcons.js",
|
"src/data/transitIcons.js",
|
||||||
"src/data/transitPresetStyle.js",
|
"src/data/transitPresetStyle.js",
|
||||||
"src/layers/transit/controls.js",
|
"src/layers/transit/controls.js",
|
||||||
@@ -3946,6 +3802,8 @@
|
|||||||
"src/ui/stores/bhoteKoshiPanel.js",
|
"src/ui/stores/bhoteKoshiPanel.js",
|
||||||
"src/hudReadouts.js",
|
"src/hudReadouts.js",
|
||||||
"src/ui/i18n.js",
|
"src/ui/i18n.js",
|
||||||
|
"src/time/worldClock.js",
|
||||||
|
"src/time/julian.js",
|
||||||
"src/annotations/outlineRungs.js",
|
"src/annotations/outlineRungs.js",
|
||||||
"src/sources/openFreeMapOutlines.js",
|
"src/sources/openFreeMapOutlines.js",
|
||||||
"src/sources/nominatimOutlines.js",
|
"src/sources/nominatimOutlines.js",
|
||||||
@@ -3953,33 +3811,7 @@
|
|||||||
"src/data/placeBoundaries.js",
|
"src/data/placeBoundaries.js",
|
||||||
"src/data/placeNames.js",
|
"src/data/placeNames.js",
|
||||||
"src/data/local_data/us_census_places/files.js",
|
"src/data/local_data/us_census_places/files.js",
|
||||||
"src/data/local_data/wof_neighborhoods/files.js",
|
"src/data/local_data/wof_neighborhoods/files.js"
|
||||||
"src/layers/osmPlaces/model.js",
|
|
||||||
"src/layers/osmPlaces/index.js",
|
|
||||||
"src/app/layers/osmPlaces.js",
|
|
||||||
"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": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -4111,7 +3943,6 @@
|
|||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
"src/data/idSwitches.js",
|
"src/data/idSwitches.js",
|
||||||
"src/data/firmsIntensity.js",
|
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateCoordinator.js",
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
@@ -4132,7 +3963,8 @@
|
|||||||
"src/i18n/locales/ru.json",
|
"src/i18n/locales/ru.json",
|
||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json"
|
"src/i18n/locales/zh-Hant.json",
|
||||||
|
"src/time/worldClock.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -4345,7 +4177,6 @@
|
|||||||
"exports": ["./voice/action-schemas"],
|
"exports": ["./voice/action-schemas"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/voice/actionSchemas.js",
|
"src/voice/actionSchemas.js",
|
||||||
"src/voice/areaCandidateContract.js",
|
|
||||||
"src/voice/layerManifest.js",
|
"src/voice/layerManifest.js",
|
||||||
"src/hudLayoutPolicy.js"
|
"src/hudLayoutPolicy.js"
|
||||||
],
|
],
|
||||||
@@ -4363,15 +4194,6 @@
|
|||||||
"src/voice/sessionCommands.js",
|
"src/voice/sessionCommands.js",
|
||||||
"src/voice/session.js",
|
"src/voice/session.js",
|
||||||
"src/voice/control.js",
|
"src/voice/control.js",
|
||||||
"src/voice/chatControl.js",
|
|
||||||
"src/voice/cloudVoiceAuth.js",
|
|
||||||
"src/voice/geminiTextChat.js",
|
|
||||||
"src/voice/textChat.js",
|
|
||||||
"src/voice/textProtocol.js",
|
|
||||||
"src/voice/chatAttachments.js",
|
|
||||||
"src/voice/chatPreferences.js",
|
|
||||||
"src/voice/panelLayout.js",
|
|
||||||
"src/voice/voiceMiniPreview.js",
|
|
||||||
"src/voice/voiceCard.js",
|
"src/voice/voiceCard.js",
|
||||||
"src/voice/voiceCardPresentation.js",
|
"src/voice/voiceCardPresentation.js",
|
||||||
"src/voice/resultDisplay.js",
|
"src/voice/resultDisplay.js",
|
||||||
@@ -4513,17 +4335,7 @@
|
|||||||
"src/layers/submarineCables/bundledSource.js",
|
"src/layers/submarineCables/bundledSource.js",
|
||||||
"src/layers/gnss/source.js",
|
"src/layers/gnss/source.js",
|
||||||
"src/layers/airQuality/model.js",
|
"src/layers/airQuality/model.js",
|
||||||
"src/layers/airQuality/source.js",
|
"src/layers/airQuality/source.js"
|
||||||
"src/layers/droneAirspace/source.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": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -4575,7 +4387,6 @@
|
|||||||
"server/providers/transitHistory.js",
|
"server/providers/transitHistory.js",
|
||||||
"src/data/gtfsRealtime.js",
|
"src/data/gtfsRealtime.js",
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.js",
|
|
||||||
"src/geoDistance.js",
|
"src/geoDistance.js",
|
||||||
"src/data/transitProxy.js",
|
"src/data/transitProxy.js",
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
@@ -4592,7 +4403,6 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
"src/data/gtfsRealtime.js",
|
"src/data/gtfsRealtime.js",
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.js",
|
|
||||||
"src/geoDistance.js",
|
"src/geoDistance.js",
|
||||||
"src/data/transitProxy.js",
|
"src/data/transitProxy.js",
|
||||||
"src/sources/transitHistory.js"
|
"src/sources/transitHistory.js"
|
||||||
@@ -4626,7 +4436,6 @@
|
|||||||
"src/data/terrainHeights.js",
|
"src/data/terrainHeights.js",
|
||||||
"src/data/transit.js",
|
"src/data/transit.js",
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.js",
|
|
||||||
"src/data/transitIcons.js",
|
"src/data/transitIcons.js",
|
||||||
"src/data/transitPresetStyle.js",
|
"src/data/transitPresetStyle.js",
|
||||||
"src/data/transitProxy.js",
|
"src/data/transitProxy.js",
|
||||||
@@ -4675,7 +4484,7 @@
|
|||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json",
|
"src/i18n/locales/zh-Hant.json",
|
||||||
"src/overlays/worldOverlayChrome.js"
|
"src/time/worldClock.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "egm96-universal", "pbf"]
|
"external": ["cesium", "egm96-universal", "pbf"]
|
||||||
},
|
},
|
||||||
@@ -4686,7 +4495,6 @@
|
|||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
"src/data/inputOwnership.js",
|
"src/data/inputOwnership.js",
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.js",
|
|
||||||
"src/geoDistance.js",
|
"src/geoDistance.js",
|
||||||
"src/data/transitIcons.js",
|
"src/data/transitIcons.js",
|
||||||
"src/data/transitPresetStyle.js",
|
"src/data/transitPresetStyle.js",
|
||||||
@@ -4728,7 +4536,8 @@
|
|||||||
"src/i18n/locales/ru.json",
|
"src/i18n/locales/ru.json",
|
||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json"
|
"src/i18n/locales/zh-Hant.json",
|
||||||
|
"src/time/worldClock.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "pbf"]
|
"external": ["cesium", "pbf"]
|
||||||
},
|
},
|
||||||
@@ -4744,7 +4553,6 @@
|
|||||||
"exports": ["./sources/transit-service"],
|
"exports": ["./sources/transit-service"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.js",
|
|
||||||
"src/geoDistance.js",
|
"src/geoDistance.js",
|
||||||
"src/data/transitProxy.js",
|
"src/data/transitProxy.js",
|
||||||
"src/data/gtfsRealtime.js",
|
"src/data/gtfsRealtime.js",
|
||||||
@@ -4843,14 +4651,14 @@
|
|||||||
"src/styles/surveillance.js",
|
"src/styles/surveillance.js",
|
||||||
"src/styles/thermal.js",
|
"src/styles/thermal.js",
|
||||||
"src/view/index.js",
|
"src/view/index.js",
|
||||||
"src/data/firmsIntensity.js",
|
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/idSwitches.js",
|
"src/data/idSwitches.js",
|
||||||
"src/data/detectionPolicy.js",
|
"src/data/detectionPolicy.js",
|
||||||
"src/scopeMaskPolicy.js",
|
"src/scopeMaskPolicy.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/visualStylePolicy.js"
|
"src/visualStylePolicy.js",
|
||||||
|
"src/time/worldClock.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -4910,41 +4718,6 @@
|
|||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"drone-airspace": {
|
|
||||||
"exports": ["./layers/drone-airspace"],
|
|
||||||
"modules": [
|
|
||||||
"src/layers/droneAirspace/index.js",
|
|
||||||
"src/sources/httpBody.js",
|
|
||||||
"src/layers/droneAirspace/source.js",
|
|
||||||
"src/layers/droneAirspace/records.js",
|
|
||||||
"src/i18n/marker.js",
|
|
||||||
"src/layers/droneAirspace/cards.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"
|
|
||||||
],
|
|
||||||
"external": ["cesium"]
|
|
||||||
},
|
|
||||||
"drone-airspace-source": {
|
|
||||||
"exports": ["./layers/drone-airspace/source"],
|
|
||||||
"modules": [
|
|
||||||
"src/layers/droneAirspace/records.js",
|
|
||||||
"src/sources/httpBody.js",
|
|
||||||
"src/layers/droneAirspace/source.js",
|
|
||||||
"src/i18n/marker.js"
|
|
||||||
],
|
|
||||||
"external": []
|
|
||||||
},
|
|
||||||
"tools": {
|
"tools": {
|
||||||
"exports": ["./tools"],
|
"exports": ["./tools"],
|
||||||
"modules": [
|
"modules": [
|
||||||
@@ -4965,8 +4738,6 @@
|
|||||||
"src/tools/queries/brief.js",
|
"src/tools/queries/brief.js",
|
||||||
"src/tools/queries/maritime.js",
|
"src/tools/queries/maritime.js",
|
||||||
"src/tools/queries/share.js",
|
"src/tools/queries/share.js",
|
||||||
"src/data/firmsIntensity.js",
|
|
||||||
"src/data/firmsAdapt.js",
|
|
||||||
"src/data/idSwitches.js",
|
"src/data/idSwitches.js",
|
||||||
"src/data/vesselTypes.js",
|
"src/data/vesselTypes.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
@@ -4989,7 +4760,6 @@
|
|||||||
"src/tools/surfaces.js",
|
"src/tools/surfaces.js",
|
||||||
"src/layers/transit/policy.js",
|
"src/layers/transit/policy.js",
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.js",
|
|
||||||
"src/layers/transit/movement.js",
|
"src/layers/transit/movement.js",
|
||||||
"src/data/contactPlayback.js",
|
"src/data/contactPlayback.js",
|
||||||
"src/data/layerSnapshot.js",
|
"src/data/layerSnapshot.js",
|
||||||
@@ -5102,7 +4872,6 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
"src/view/index.js",
|
"src/view/index.js",
|
||||||
"src/data/idSwitches.js",
|
"src/data/idSwitches.js",
|
||||||
"src/data/firmsIntensity.js",
|
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/visualStylePolicy.js"
|
"src/visualStylePolicy.js"
|
||||||
@@ -5157,68 +4926,5 @@
|
|||||||
"src/i18n/marker.js"
|
"src/i18n/marker.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"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": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,8 +19,6 @@ export const PINOKIO_CONFIG_FIELDS = Object.freeze([
|
|||||||
'LL2_API_TOKEN',
|
'LL2_API_TOKEN',
|
||||||
'GEV_RATELIMIT_OPENAI_PER_MIN',
|
'GEV_RATELIMIT_OPENAI_PER_MIN',
|
||||||
'GEV_RATELIMIT_GOOGLE_PER_MIN',
|
'GEV_RATELIMIT_GOOGLE_PER_MIN',
|
||||||
'GEV_RATELIMIT_OPENAI_CHAT_PER_MIN',
|
|
||||||
'GEV_RATELIMIT_GEMINI_CHAT_PER_MIN',
|
|
||||||
'PINOKIO_SHARE_CLOUDFLARE',
|
'PINOKIO_SHARE_CLOUDFLARE',
|
||||||
'PINOKIO_SHARE_LOCAL',
|
'PINOKIO_SHARE_LOCAL',
|
||||||
'PINOKIO_SHARE_VAR',
|
'PINOKIO_SHARE_VAR',
|
||||||
|
|||||||
@@ -28,8 +28,6 @@
|
|||||||
* (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled:
|
* (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
|
* seen from over the Indian Ocean no pin clearly behind the planet is
|
||||||
* shown; back over North America the near-side pins show again.
|
* 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).
|
* Screenshots go to qa-shots/ghostmaps-*.png (gitignored).
|
||||||
*
|
*
|
||||||
@@ -226,77 +224,6 @@ async function main() {
|
|||||||
record('LIVE: the /api/ghostmaps/events stream is open', stream);
|
record('LIVE: the /api/ghostmaps/events stream is open', stream);
|
||||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') });
|
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 ----------------------------------------------------------
|
// ---- (ii) PICK ----------------------------------------------------------
|
||||||
const target = await page.evaluate((id) => {
|
const target = await page.evaluate((id) => {
|
||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
|
|||||||
+3
-20
@@ -3073,27 +3073,20 @@ async function main() {
|
|||||||
expectedSlot: targetIndex,
|
expectedSlot: targetIndex,
|
||||||
previewPlayDelta: previewPlays - playsBefore,
|
previewPlayDelta: previewPlays - playsBefore,
|
||||||
commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore,
|
commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore,
|
||||||
audioState: state.audioState,
|
|
||||||
tuningStatic: state.tuningStatic,
|
|
||||||
samples,
|
samples,
|
||||||
spread: Math.max(...samples) - Math.min(...samples),
|
spread: Math.max(...samples) - Math.min(...samples),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
check(
|
check(
|
||||||
'from a stopped Radio, pointer release only selects the nearest station, silently and without a needle sweep',
|
'pointer preview never plays and release commits the nearest station once without a needle sweep',
|
||||||
tunerDirectRelease.targetId
|
tunerDirectRelease.targetId
|
||||||
&& tunerDirectRelease.selectedId === tunerDirectRelease.targetId
|
&& tunerDirectRelease.selectedId === tunerDirectRelease.targetId
|
||||||
&& tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot
|
&& tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot
|
||||||
&& tunerDirectRelease.previewPlayDelta === 0
|
&& tunerDirectRelease.previewPlayDelta === 0
|
||||||
&& tunerDirectRelease.commitPlayDelta === 0
|
&& tunerDirectRelease.commitPlayDelta === 1
|
||||||
&& tunerDirectRelease.audioState === 'stopped'
|
|
||||||
&& !tunerDirectRelease.tuningStatic
|
|
||||||
&& tunerDirectRelease.spread < 0.5,
|
&& tunerDirectRelease.spread < 0.5,
|
||||||
JSON.stringify(tunerDirectRelease),
|
JSON.stringify(tunerDirectRelease),
|
||||||
);
|
);
|
||||||
// The release plays only if a stream was active when the drag began.
|
|
||||||
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
|
|
||||||
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
|
||||||
// Earlier cases scroll and resize the directory. Bring the physical drag
|
// Earlier cases scroll and resize the directory. Bring the physical drag
|
||||||
// target back into its scroller before deriving viewport mouse coordinates.
|
// target back into its scroller before deriving viewport mouse coordinates.
|
||||||
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
|
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
|
||||||
@@ -3501,9 +3494,6 @@ async function main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
// Next left the Radio stopped; a release plays only from an active stream.
|
|
||||||
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
|
|
||||||
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
|
||||||
const failedTuner = await page.evaluate(async () => {
|
const failedTuner = await page.evaluate(async () => {
|
||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
const slider = document.getElementById('radio-tuner-slider');
|
const slider = document.getElementById('radio-tuner-slider');
|
||||||
@@ -3702,14 +3692,7 @@ async function main() {
|
|||||||
radio.stopPlayback();
|
radio.stopPlayback();
|
||||||
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
|
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
|
||||||
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
|
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
|
||||||
// Next plays only while a stream is active, so start from a playing station.
|
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
|
||||||
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: true, focus: false });
|
|
||||||
});
|
|
||||||
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const gev = window.__godsEyeView;
|
|
||||||
const camera = gev.viewer.camera;
|
|
||||||
const radio = gev.dataManager.layers.get('radio').module;
|
|
||||||
const pool = gev.styleManager._radioControls._radioTunerPool;
|
const pool = gev.styleManager._radioControls._radioTunerPool;
|
||||||
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
|
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
|
||||||
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
|
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
|
||||||
|
|||||||
@@ -296,22 +296,6 @@ 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) {
|
async function assertKeyRequired(page) {
|
||||||
await page.evaluate(() =>
|
await page.evaluate(() =>
|
||||||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||||||
@@ -544,25 +528,11 @@ async function main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await expandPanel(page);
|
await expandPanel(page);
|
||||||
if (!configured && litAlone(await getUI(page), 'mapillary')) {
|
if (!configured) {
|
||||||
// A keyed bundle behind a server without the token keeps Mapillary.
|
|
||||||
await step('keyless server: KEY REQUIRED gates the controls', () =>
|
await step('keyless server: KEY REQUIRED gates the controls', () =>
|
||||||
assertKeyRequired(page),
|
assertKeyRequired(page),
|
||||||
);
|
);
|
||||||
skip('no Mapillary key', 'the keyed steps');
|
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 {
|
} else {
|
||||||
await park(page);
|
await park(page);
|
||||||
await step(
|
await step(
|
||||||
@@ -781,8 +751,6 @@ async function main() {
|
|||||||
await park(page);
|
await park(page);
|
||||||
|
|
||||||
await fixtureStep(
|
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',
|
'keyless server (second page): KEY REQUIRED gates the controls',
|
||||||
async () => {
|
async () => {
|
||||||
const keyless = await open(
|
const keyless = await open(
|
||||||
|
|||||||
+14
-235
@@ -29,7 +29,6 @@
|
|||||||
* node scripts/qa-voice-routing.mjs --layer behavior --deixis-only --headful --shots qa-shots/point-and-ask
|
* node scripts/qa-voice-routing.mjs --layer behavior --deixis-only --headful --shots qa-shots/point-and-ask
|
||||||
* node scripts/qa-voice-routing.mjs --layer deixis --budget 12 # pointer_context routing (model turns)
|
* node scripts/qa-voice-routing.mjs --layer deixis --budget 12 # pointer_context routing (model turns)
|
||||||
* node scripts/qa-voice-routing.mjs --layer deixis-live --url ... # in-app text turns (model turns)
|
* node scripts/qa-voice-routing.mjs --layer deixis-live --url ... # in-app text turns (model turns)
|
||||||
* node scripts/qa-voice-routing.mjs --layer behavior --behavior geometry # areas, drawn counts, imagery
|
|
||||||
*
|
*
|
||||||
* House gotchas honored: camera.cancelFlight() before every teleport; puppeteer
|
* House gotchas honored: camera.cancelFlight() before every teleport; puppeteer
|
||||||
* suites must run sequentially with other harnesses (SwiftShader saturation);
|
* suites must run sequentially with other harnesses (SwiftShader saturation);
|
||||||
@@ -76,7 +75,7 @@ const LAYER = getOpt('--layer', 'all'); // routing | behavior | all | deixis | d
|
|||||||
const TURN_BUDGET = Number(getOpt('--budget', '120'));
|
const TURN_BUDGET = Number(getOpt('--budget', '120'));
|
||||||
const PHRASES_PER_SESSION = Number(getOpt('--batch', '6'));
|
const PHRASES_PER_SESSION = Number(getOpt('--batch', '6'));
|
||||||
const ONLY = getOpt('--only', null); // substring filter on phrase text
|
const ONLY = getOpt('--only', null); // substring filter on phrase text
|
||||||
const BEHAVIOR = getOpt('--behavior', 'all'); // all | outlines | acceptance | geometry
|
const BEHAVIOR = getOpt('--behavior', 'all'); // all | outlines | acceptance
|
||||||
const DEIXIS_ONLY = process.argv.includes('--deixis-only'); // behavior: point-and-ask block only
|
const DEIXIS_ONLY = process.argv.includes('--deixis-only'); // behavior: point-and-ask block only
|
||||||
const HEADFUL = process.argv.includes('--headful'); // real GPU, visible window
|
const HEADFUL = process.argv.includes('--headful'); // real GPU, visible window
|
||||||
const SHOT_DIR = getOpt('--shots', null); // point-and-ask evidence screenshots
|
const SHOT_DIR = getOpt('--shots', null); // point-and-ask evidence screenshots
|
||||||
@@ -165,11 +164,7 @@ async function mintSession() {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
`token mint returned no client secret: ${JSON.stringify(body).slice(0, 160)}`,
|
`token mint returned no client secret: ${JSON.stringify(body).slice(0, 160)}`,
|
||||||
);
|
);
|
||||||
// The tools this server offered (osm_query only with an operator Overpass).
|
return { value, model };
|
||||||
const tools = new Set(
|
|
||||||
(body?.session?.tools || []).map((tool) => tool?.name).filter(Boolean),
|
|
||||||
);
|
|
||||||
return { value, model, tools };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One Realtime WS session that can run several text turns sequentially. */
|
/** One Realtime WS session that can run several text turns sequentially. */
|
||||||
@@ -376,10 +371,8 @@ async function runRoutingLayer() {
|
|||||||
}
|
}
|
||||||
const batch = list.slice(i, i + PHRASES_PER_SESSION);
|
const batch = list.slice(i, i + PHRASES_PER_SESSION);
|
||||||
let session;
|
let session;
|
||||||
let offered = new Set();
|
|
||||||
try {
|
try {
|
||||||
const { value, model, tools } = await mintSession();
|
const { value, model } = await mintSession();
|
||||||
offered = tools;
|
|
||||||
session = new RoutingSession(value, model, evidence);
|
session = new RoutingSession(value, model, evidence);
|
||||||
await session.connect();
|
await session.connect();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -397,14 +390,6 @@ async function runRoutingLayer() {
|
|||||||
skipped(`route: "${p.phrase}"`, 'turn budget exhausted');
|
skipped(`route: "${p.phrase}"`, 'turn budget exhausted');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const missing = (p.requires || []).filter((name) => !offered.has(name));
|
|
||||||
if (missing.length) {
|
|
||||||
skipped(
|
|
||||||
`route: "${p.phrase}"`,
|
|
||||||
`${missing.join(', ')} not offered (no configured Overpass)`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
turnsUsed += 1;
|
turnsUsed += 1;
|
||||||
let calls = [];
|
let calls = [];
|
||||||
try {
|
try {
|
||||||
@@ -1376,11 +1361,7 @@ async function runBehaviorLayer() {
|
|||||||
// The point-and-ask block skips the first-run launcher so its evidence
|
// The point-and-ask block skips the first-run launcher so its evidence
|
||||||
// screenshots show the map; the full run keeps the historical URL.
|
// screenshots show the map; the full run keeps the historical URL.
|
||||||
const behaviorUrl = new URL(APP_URL);
|
const behaviorUrl = new URL(APP_URL);
|
||||||
if (
|
if (DEIXIS_ONLY || BEHAVIOR === 'acceptance')
|
||||||
DEIXIS_ONLY ||
|
|
||||||
BEHAVIOR === 'acceptance' ||
|
|
||||||
BEHAVIOR === 'geometry'
|
|
||||||
)
|
|
||||||
behaviorUrl.searchParams.set('welcome', '0');
|
behaviorUrl.searchParams.set('welcome', '0');
|
||||||
await page.goto(behaviorUrl.href, {
|
await page.goto(behaviorUrl.href, {
|
||||||
waitUntil: 'domcontentloaded',
|
waitUntil: 'domcontentloaded',
|
||||||
@@ -1439,10 +1420,6 @@ async function runBehaviorLayer() {
|
|||||||
await runOutlineAcceptance({ page, run, settle, overpassRequests });
|
await runOutlineAcceptance({ page, run, settle, overpassRequests });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (BEHAVIOR === 'geometry') {
|
|
||||||
await runGeometryScenarios({ page, run, settle, overpassRequests });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// (1) fly_to_location lands near the target. Annotations follow IMMEDIATELY
|
// (1) fly_to_location lands near the target. Annotations follow IMMEDIATELY
|
||||||
// while the camera is local — the resolver's proximity gate rejects far
|
// while the camera is local — the resolver's proximity gate rejects far
|
||||||
@@ -2321,11 +2298,9 @@ async function runBehaviorLayer() {
|
|||||||
`result=${JSON.stringify(r)?.slice(0, 120)} state=${JSON.stringify(contextExited)?.slice(0, 80)}`,
|
`result=${JSON.stringify(r)?.slice(0, 120)} state=${JSON.stringify(contextExited)?.slice(0, 80)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// (9f) Cockpit needs only the tracked aircraft's own layer, not Contacts.
|
// (9f) With Contacts OFF the entry gate is shut. Entry must refuse with the
|
||||||
// With Contacts OFF, status must explain a shut gate without blaming
|
// reason rather than produce the half-entered state the operator saw: a
|
||||||
// Contacts, and entry must either enter cleanly or refuse with the reason
|
// plane anchored under the camera with no HUD and no exit control.
|
||||||
// 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' });
|
r = await run('control_cockpit', { action: 'status' });
|
||||||
const gateReport = r?.state || {};
|
const gateReport = r?.state || {};
|
||||||
const cockpitBeforeGate = await page.evaluate(() =>
|
const cockpitBeforeGate = await page.evaluate(() =>
|
||||||
@@ -2333,13 +2308,10 @@ async function runBehaviorLayer() {
|
|||||||
);
|
);
|
||||||
report(
|
report(
|
||||||
gateReport.active === false &&
|
gateReport.active === false &&
|
||||||
(gateReport.entryAllowed === true
|
gateReport.entryAllowed === false &&
|
||||||
? gateReport.entryBlockedReason === null
|
gateReport.entryBlockedReason === 'contacts-inactive' &&
|
||||||
: ['subject-layer-off', 'no-tracked-aircraft'].includes(
|
|
||||||
gateReport.entryBlockedReason,
|
|
||||||
)) &&
|
|
||||||
!cockpitBeforeGate,
|
!cockpitBeforeGate,
|
||||||
'behavior: cockpit status explains its gate without requiring Contacts',
|
'behavior: cockpit status names why entry is blocked with Contacts off',
|
||||||
`state=${JSON.stringify(gateReport)?.slice(0, 150)}`,
|
`state=${JSON.stringify(gateReport)?.slice(0, 150)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2353,16 +2325,12 @@ async function runBehaviorLayer() {
|
|||||||
Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active),
|
Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active),
|
||||||
);
|
);
|
||||||
report(
|
report(
|
||||||
gated?.ok === true
|
gated?.ok === false &&
|
||||||
? cockpitAfterGate
|
/contacts/i.test(gated?.error || '') &&
|
||||||
: !cockpitAfterGate && !/contacts/i.test(gated?.error || ''),
|
!cockpitAfterGate,
|
||||||
'behavior: cockpit entry with Contacts off follows the own-layer gate, never half-entered',
|
'behavior: cockpit entry with Contacts off is refused, not half-entered',
|
||||||
`ok=${gated?.ok} error=${String(gated?.error || '').slice(0, 90)} active=${cockpitAfterGate}`,
|
`ok=${gated?.ok} error=${String(gated?.error || '').slice(0, 90)} active=${cockpitAfterGate}`,
|
||||||
);
|
);
|
||||||
if (cockpitAfterGate)
|
|
||||||
await page.evaluate(() =>
|
|
||||||
window.__godsEyeView?.styleManager?.controlCockpit?.('exit'),
|
|
||||||
);
|
|
||||||
|
|
||||||
await runBundledOutlineScenarios({
|
await runBundledOutlineScenarios({
|
||||||
page,
|
page,
|
||||||
@@ -2372,8 +2340,6 @@ async function runBehaviorLayer() {
|
|||||||
resolverLog,
|
resolverLog,
|
||||||
packTimes,
|
packTimes,
|
||||||
});
|
});
|
||||||
// Voice geometry last: it moves the camera and turns layers off.
|
|
||||||
await runGeometryScenarios({ page, run, settle, overpassRequests });
|
|
||||||
|
|
||||||
const shotDir = path.join(ROOT, 'qa-shots');
|
const shotDir = path.join(ROOT, 'qa-shots');
|
||||||
fs.mkdirSync(shotDir, { recursive: true });
|
fs.mkdirSync(shotDir, { recursive: true });
|
||||||
@@ -2517,193 +2483,6 @@ const OUTLINE_ASKS = [
|
|||||||
{ entityKind: 'building', latitude: 30.27472, longitude: -97.74035 }, 'building'],
|
{ entityKind: 'building', latitude: 30.27472, longitude: -97.74035 }, 'building'],
|
||||||
].map(([group, target, lat, lon, height, extra = {}, shot = null]) => ({ group, target, lat, lon, height, extra, shot }));
|
].map(([group, target, lat, lon, height, extra = {}, shot = null]) => ({ group, target, lat, lon, height, extra, shot }));
|
||||||
|
|
||||||
/**
|
|
||||||
* Voice geometry, runner-driven: reusable areas, drawn-area counts, a
|
|
||||||
* landmark area and recent imagery, all without a default Overpass request.
|
|
||||||
* Public Nominatim use is read from the server's daily count.
|
|
||||||
*/
|
|
||||||
async function runGeometryScenarios({ page, run, settle, overpassRequests }) {
|
|
||||||
const shotDir = path.join(ROOT, 'qa-shots', 'pr-geometry');
|
|
||||||
fs.mkdirSync(shotDir, { recursive: true });
|
|
||||||
const shot = (name) => page.screenshot({ path: path.join(shotDir, `${name}.png`) }).catch(() => {});
|
|
||||||
const nominatimStart = nominatimCount();
|
|
||||||
const overpassStart = overpassRequests.length;
|
|
||||||
const teleport = (lat, lon, height, pitch = -90) => page.evaluate(({ lat, lon, height, pitch }) => {
|
|
||||||
const { viewer } = window.__godsEyeView;
|
|
||||||
viewer.camera.cancelFlight();
|
|
||||||
viewer.camera.setView({
|
|
||||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
|
||||||
longitude: (lon * Math.PI) / 180, latitude: (lat * Math.PI) / 180, height,
|
|
||||||
}),
|
|
||||||
orientation: { heading: 0, pitch: (pitch * Math.PI) / 180, roll: 0 },
|
|
||||||
});
|
|
||||||
}, { lat, lon, height, pitch });
|
|
||||||
const timings = {};
|
|
||||||
|
|
||||||
// (G1) "Outline Bagmati, then count fires inside it".
|
|
||||||
await teleport(27.7, 85.4, 400_000);
|
|
||||||
await settle(1500);
|
|
||||||
let t0 = Date.now();
|
|
||||||
const bagmati = await run('resolve_area', { query: 'Bagmati province', draw: true, flyTo: false });
|
|
||||||
timings.bagmatiMs = Date.now() - t0;
|
|
||||||
const bagmatiRefused = [
|
|
||||||
'AREA_IDENTITY_MISMATCH',
|
|
||||||
'AREA_IDENTITY_UNVERIFIED',
|
|
||||||
].includes(bagmati?.code);
|
|
||||||
const bagmatiVerified =
|
|
||||||
bagmati?.ok === true &&
|
|
||||||
bagmati?.drawn === true &&
|
|
||||||
bagmati?.level === 'admin1' &&
|
|
||||||
bagmati?.name === 'Bagmati Province' &&
|
|
||||||
bagmati?.sourceIdentity === 'Bagmati Province';
|
|
||||||
report(
|
|
||||||
bagmatiVerified || bagmatiRefused,
|
|
||||||
'geometry: "outline Bagmati province" uses verified current identity or refuses honestly',
|
|
||||||
`areaId=${bagmati?.areaId} name=${bagmati?.name} sourceIdentity=${bagmati?.sourceIdentity} source=${bagmati?.source} km2=${bagmati?.areaKm2} ms=${timings.bagmatiMs} code=${bagmati?.code || ''} ${bagmati?.error || ''}`,
|
|
||||||
);
|
|
||||||
await settle(3000);
|
|
||||||
await shot('bagmati-outline');
|
|
||||||
t0 = Date.now();
|
|
||||||
let r = await run('resolve_area', { query: 'Bagmati province' });
|
|
||||||
report(
|
|
||||||
bagmatiVerified
|
|
||||||
? r?.ok === true &&
|
|
||||||
r?.cached === true &&
|
|
||||||
r?.ms < 300 &&
|
|
||||||
r?.sourceIdentity === 'Bagmati Province'
|
|
||||||
: bagmatiRefused &&
|
|
||||||
r?.ok === false &&
|
|
||||||
r?.code === bagmati?.code &&
|
|
||||||
!r?.areaId,
|
|
||||||
'geometry: a repeated Bagmati request reuses only verified identity, never refused geometry',
|
|
||||||
`ok=${r?.ok} code=${r?.code || ''} areaId=${r?.areaId || ''} sourceIdentity=${r?.sourceIdentity || ''} cached=${r?.cached} ms=${r?.ms} roundTrip=${Date.now() - t0}`,
|
|
||||||
);
|
|
||||||
// A city no bundled pack holds: the guarded Nominatim outline route.
|
|
||||||
t0 = Date.now();
|
|
||||||
const kathmandu = await run('resolve_area', { query: 'Kathmandu', draw: true, flyTo: false });
|
|
||||||
timings.kathmanduMs = Date.now() - t0;
|
|
||||||
report(kathmandu?.ok === true && kathmandu?.drawn === true && kathmandu?.source === 'osm' && kathmandu?.areaKm2 > 10 && kathmandu?.areaKm2 < 2000,
|
|
||||||
'geometry: "outline Kathmandu" comes from the guarded Nominatim outline',
|
|
||||||
`areaId=${kathmandu?.areaId} source=${kathmandu?.source} km2=${kathmandu?.areaKm2} parts=${kathmandu?.parts} ms=${timings.kathmanduMs} err=${kathmandu?.code || ''} ${kathmandu?.error || ''}`);
|
|
||||||
await settle(2500);
|
|
||||||
await shot('kathmandu-outline');
|
|
||||||
r = await run('resolve_area', { query: 'Georgia' });
|
|
||||||
report(r?.ok === false && r?.needsClarification === true && (r?.candidates || []).length >= 2,
|
|
||||||
'geometry: an ambiguous area asks with candidates instead of guessing',
|
|
||||||
`candidates=${(r?.candidates || []).map((c) => `${c.name}/${c.level}/${c.country || ''}`).join(', ')}`,
|
|
||||||
);
|
|
||||||
if (bagmatiVerified) {
|
|
||||||
await run('set_layer_visibility', {
|
|
||||||
layerId: 'local-firms',
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
let fires = null;
|
|
||||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
||||||
fires = await run('analyst_query', {
|
|
||||||
layers: ['local-firms'],
|
|
||||||
scope: { kind: 'area', areaId: bagmati.areaId },
|
|
||||||
});
|
|
||||||
if (fires?.ok || !['NOT_READY', 'LAYER_OFF'].includes(fires?.code)) break;
|
|
||||||
await settle(4000);
|
|
||||||
}
|
|
||||||
if (fires?.code === 'FEED_UNAVAILABLE') {
|
|
||||||
skipped(
|
|
||||||
'geometry: "count fires inside it" counts inside the verified Bagmati area',
|
|
||||||
`fires feed unavailable: ${fires?.error}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
report(
|
|
||||||
fires?.ok === true &&
|
|
||||||
Number.isFinite(fires?.count) &&
|
|
||||||
/Bagmati/i.test(fires?.scopeLabel || '') &&
|
|
||||||
fires?.coverage?.scope === `area:${bagmati.areaId}`,
|
|
||||||
'geometry: "count fires inside it" counts inside the verified Bagmati area',
|
|
||||||
`count=${fires?.count} scopeLabel="${fires?.scopeLabel}" scope=${fires?.coverage?.scope} err=${fires?.error || ''}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await run('set_layer_visibility', {
|
|
||||||
layerId: 'local-firms',
|
|
||||||
enabled: false,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
skipped(
|
|
||||||
'geometry: fire count waits for a verified Bagmati area',
|
|
||||||
`boundary refused with ${bagmati?.code || 'unknown error'}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// (G2) "Count flights inside the area I drew": the draw tool's own spec.
|
|
||||||
await teleport(30.27, -97.74, 600_000);
|
|
||||||
await run('set_layer_visibility', { layerId: 'flights', enabled: true });
|
|
||||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
||||||
r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'anywhere' } });
|
|
||||||
if (r?.ok && r.count > 0) break;
|
|
||||||
await settle(5000);
|
|
||||||
}
|
|
||||||
const drawnBox = [[-99, 29], [-96.5, 29], [-96.5, 31.5], [-99, 31.5], [-99, 29]];
|
|
||||||
await page.evaluate((ring) => window.__gevAnnotations.annotate([{ type: 'area', manual: true, ring, label: 'QA drawn box' }]), drawnBox);
|
|
||||||
r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'drawn' } });
|
|
||||||
const loaded = await run('analyst_query', { layers: ['flights'], scope: { kind: 'anywhere' } });
|
|
||||||
report(r?.ok === true && r?.scopeLabel === 'inside QA drawn box' && Number.isFinite(r?.count) && r.count <= (loaded?.count ?? Infinity),
|
|
||||||
'geometry: "count flights inside the area I drew" counts inside the drawn outline',
|
|
||||||
`count=${r?.count} of ${loaded?.count} loaded scopeLabel="${r?.scopeLabel}" err=${r?.error || ''}`);
|
|
||||||
await settle(2500);
|
|
||||||
await shot('drawn-area-count');
|
|
||||||
// Thousands of aircraft starve a SwiftShader page; the landmark lookup
|
|
||||||
// below has a 10 s budget.
|
|
||||||
await run('set_layer_visibility', { layerId: 'flights', enabled: false });
|
|
||||||
await run('clear_annotations', {});
|
|
||||||
|
|
||||||
// (G3) "Draw an area around the Ferry Building": the enclosing mapped area
|
|
||||||
// (Nominatim grounds, then the OpenFreeMap area holding it), else an
|
|
||||||
// approximate buffer — either way a drawn, reusable area.
|
|
||||||
await teleport(37.7955, -122.3937, 2500, -60);
|
|
||||||
await settle(1500);
|
|
||||||
t0 = Date.now();
|
|
||||||
const ferry = await run('resolve_area', { query: 'Ferry Building, San Francisco', around: true, flyTo: false });
|
|
||||||
timings.ferryMs = Date.now() - t0;
|
|
||||||
const ferryNear = Array.isArray(ferry?.bbox) && Math.abs(ferry.bbox[1] - 37.795) < 0.05 && Math.abs(ferry.bbox[0] + 122.394) < 0.05;
|
|
||||||
report(ferry?.ok === true && ferry?.drawn === true && ferryNear && (ferry?.approximate === true || ['osm', 'openfreemap'].includes(ferry?.source)),
|
|
||||||
'geometry: "draw an area around the Ferry Building" draws a reusable landmark area',
|
|
||||||
`areaId=${ferry?.areaId} source=${ferry?.source} approximate=${ferry?.approximate} basis=${ferry?.display?.basis} km2=${ferry?.areaKm2} ms=${timings.ferryMs} err=${ferry?.error || ''}`);
|
|
||||||
await settle(3000);
|
|
||||||
await shot('ferry-building-area');
|
|
||||||
|
|
||||||
// (G4) "Find recent imagery of this area": the Recent Imagery catalog for
|
|
||||||
// that area, the best acquisition pinned through the layer itself.
|
|
||||||
t0 = Date.now();
|
|
||||||
r = await run('find_imagery', { areaId: ferry?.areaId || 'missing' });
|
|
||||||
timings.imageryMs = Date.now() - t0;
|
|
||||||
const pinned = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('recent-imagery')?.module?.getSnapshot?.().pins?.a?.key || null);
|
|
||||||
if (r?.code === 'CATALOG_UNAVAILABLE' || r?.code === 'SEARCH_TIMEOUT') {
|
|
||||||
skipped('geometry: "find recent imagery of this area" pins the best acquisition', `catalog: ${r?.error}`);
|
|
||||||
} else {
|
|
||||||
report(r?.ok === true && (r?.count === 0 ? r?.shown === null : Boolean(r?.shown?.date) && String(pinned).endsWith(`:${r.shown.date}`)),
|
|
||||||
'geometry: "find recent imagery of this area" pins the best acquisition',
|
|
||||||
`count=${r?.count} shown=${JSON.stringify(r?.shown)} pinned=${pinned} ms=${timings.imageryMs} err=${r?.error || ''}`);
|
|
||||||
}
|
|
||||||
await settle(4000);
|
|
||||||
await shot('recent-imagery-area');
|
|
||||||
await run('set_layer_visibility', { layerId: 'recent-imagery', enabled: false });
|
|
||||||
|
|
||||||
// (G5) Bulk place search without an operator Overpass: a plain refusal.
|
|
||||||
const configured = await page.evaluate(() => fetch('/api/overpass/status').then((res) => res.json()).catch(() => null));
|
|
||||||
r = await run('osm_query', { what: 'hospitals' });
|
|
||||||
if (configured?.configured) {
|
|
||||||
skipped('geometry: place search without an operator Overpass is refused', 'this server has OVERPASS_UPSTREAMS');
|
|
||||||
} else {
|
|
||||||
report(r?.ok === false && r?.code === 'OVERPASS_NOT_CONFIGURED',
|
|
||||||
'geometry: place search without an operator Overpass is refused', `code=${r?.code} error=${r?.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nominatimUsed = nominatimCount() - nominatimStart;
|
|
||||||
const overpass = overpassRequests.slice(overpassStart);
|
|
||||||
console.log(` geometry timings: ${JSON.stringify(timings)}; public Nominatim requests: ${nominatimUsed}`);
|
|
||||||
report(overpass.length === 0, 'geometry: no Overpass request', overpass.slice(0, 3).join(' | ') || 'none');
|
|
||||||
report(nominatimUsed <= 15, 'geometry: public Nominatim requests within budget (15)', `used=${nominatimUsed}`);
|
|
||||||
await run('clear_annotations', {});
|
|
||||||
}
|
|
||||||
|
|
||||||
const NOMINATIM_STATE = path.join(ROOT, '.gev-cache', 'nominatim', 'state.json');
|
const NOMINATIM_STATE = path.join(ROOT, '.gev-cache', 'nominatim', 'state.json');
|
||||||
function nominatimCount() {
|
function nominatimCount() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,333 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/** Real-app zoom and tilt regression. Start a dev server, then run this file. */
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
|
|
||||||
const origin = process.env.QA_BASE_URL || 'http://localhost:4173';
|
|
||||||
const browser = await puppeteer.launch({
|
|
||||||
headless: true,
|
|
||||||
args: [
|
|
||||||
'--no-sandbox',
|
|
||||||
...(process.platform === 'darwin'
|
|
||||||
? ['--use-angle=metal', '--enable-gpu']
|
|
||||||
: [
|
|
||||||
'--use-gl=angle',
|
|
||||||
'--use-angle=swiftshader',
|
|
||||||
'--enable-unsafe-swiftshader',
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
protocolTimeout: 120_000,
|
|
||||||
});
|
|
||||||
const errors = [];
|
|
||||||
try {
|
|
||||||
const page = await browser.newPage();
|
|
||||||
page.on('pageerror', (error) => errors.push(error.message));
|
|
||||||
await page.setViewport({ width: 1200, height: 800 });
|
|
||||||
await page.goto(`${origin}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await page.waitForFunction(
|
|
||||||
() =>
|
|
||||||
window.__godsEyeView?.viewer &&
|
|
||||||
window.__godsEyeView.viewer.scene.screenSpaceCameraController
|
|
||||||
.enableInputs &&
|
|
||||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
|
||||||
{ timeout: 90_000 },
|
|
||||||
);
|
|
||||||
const renderer = await page.evaluate(async () => {
|
|
||||||
const C = await import('/node_modules/cesium/Build/Cesium/index.js');
|
|
||||||
const { maxPitchForHeight } = await import('/src/app/zoomOutGuard.js');
|
|
||||||
const { readCameraTargetFrame } =
|
|
||||||
await import('/src/ui/cameraOrientationControls.js');
|
|
||||||
const viewer = window.__godsEyeView.viewer;
|
|
||||||
viewer.camera.cancelFlight();
|
|
||||||
viewer.trackedEntity = undefined;
|
|
||||||
// Explicit real renders make each gesture independent of host frame rate.
|
|
||||||
viewer.useDefaultRenderLoop = false;
|
|
||||||
const controller = viewer.scene.screenSpaceCameraController;
|
|
||||||
controller.inertiaZoom = 0;
|
|
||||||
controller.inertiaSpin = 0;
|
|
||||||
const original = viewer.camera.setView.bind(viewer.camera);
|
|
||||||
const qa = {
|
|
||||||
viewer,
|
|
||||||
guardWrites: 0,
|
|
||||||
targetPitch: () => readCameraTargetFrame(viewer)?.pitch,
|
|
||||||
history: [],
|
|
||||||
frame() {
|
|
||||||
viewer.scene.requestRender();
|
|
||||||
viewer.render();
|
|
||||||
qa.history.push({
|
|
||||||
height: viewer.camera._positionCartographic.height,
|
|
||||||
guardWrites: qa.guardWrites,
|
|
||||||
position: { ...viewer.camera.position },
|
|
||||||
});
|
|
||||||
if (qa.history.length > 80) qa.history.shift();
|
|
||||||
},
|
|
||||||
pose() {
|
|
||||||
return {
|
|
||||||
height: viewer.camera.positionCartographic.height,
|
|
||||||
pitch: viewer.camera.pitch,
|
|
||||||
maxPitch: maxPitchForHeight(
|
|
||||||
viewer.camera.positionCartographic.height,
|
|
||||||
),
|
|
||||||
guardWrites: qa.guardWrites,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
reset(height = 1_000_000, pitch = -35) {
|
|
||||||
viewer.camera.cancelFlight();
|
|
||||||
viewer.camera.lookAtTransform(C.Matrix4.IDENTITY);
|
|
||||||
original({
|
|
||||||
destination: C.Cartesian3.fromDegrees(0, 0, height),
|
|
||||||
orientation: { heading: 0, pitch: C.Math.toRadians(pitch), roll: 0 },
|
|
||||||
});
|
|
||||||
qa.frame();
|
|
||||||
qa.guardWrites = 0;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
viewer.camera.setView = (options) => {
|
|
||||||
// Cesium itself also calls setView while processing touch movement.
|
|
||||||
if (new Error().stack.includes('/src/app/zoomOutGuard.js'))
|
|
||||||
qa.guardWrites++;
|
|
||||||
return original(options);
|
|
||||||
};
|
|
||||||
window.__zoomGuardQa = qa;
|
|
||||||
const gl = viewer.scene.context._gl;
|
|
||||||
const debug = gl.getExtension('WEBGL_debug_renderer_info');
|
|
||||||
return gl.getParameter(debug ? debug.UNMASKED_RENDERER_WEBGL : gl.RENDERER);
|
|
||||||
});
|
|
||||||
console.log(`[INFO] WebGL renderer: ${renderer}`);
|
|
||||||
const frame = (count = 1) =>
|
|
||||||
page.evaluate(async (n) => {
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
window.__zoomGuardQa.frame();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
}
|
|
||||||
return window.__zoomGuardQa.pose();
|
|
||||||
}, count);
|
|
||||||
const reset = (height, pitch) =>
|
|
||||||
page.evaluate((h, p) => window.__zoomGuardQa.reset(h, p), height, pitch);
|
|
||||||
const session = await page.createCDPSession();
|
|
||||||
for (const viewport of [
|
|
||||||
{ width: 1200, height: 800 },
|
|
||||||
{ width: 390, height: 844 },
|
|
||||||
]) {
|
|
||||||
await page.setViewport(viewport);
|
|
||||||
await page.evaluate(() => window.__zoomGuardQa.viewer.resize());
|
|
||||||
const x = Math.round(viewport.width / 2);
|
|
||||||
const y = Math.round(viewport.height / 2);
|
|
||||||
await reset(1_000_000, -25);
|
|
||||||
await page.mouse.move(x, y);
|
|
||||||
await page.evaluate(
|
|
||||||
({ x, y }) => {
|
|
||||||
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
|
|
||||||
new WheelEvent('wheel', {
|
|
||||||
deltaY: 2400,
|
|
||||||
clientX: x,
|
|
||||||
clientY: y,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{ x, y },
|
|
||||||
);
|
|
||||||
const first = await frame();
|
|
||||||
console.log(`[INFO] ${viewport.width}px wheel delivered`);
|
|
||||||
const settled = await frame(40);
|
|
||||||
assert.ok(first.height > 1_000_000, 'wheel must move the real camera');
|
|
||||||
assert.ok(settled.guardWrites > 0, 'zoom guard must run');
|
|
||||||
assert.ok(
|
|
||||||
settled.pitch <= settled.maxPitch + 1e-6,
|
|
||||||
`stationary pitch must finish easing: ${JSON.stringify(settled)}`,
|
|
||||||
);
|
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
await page.evaluate(
|
|
||||||
({ x, y }) => {
|
|
||||||
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
|
|
||||||
new WheelEvent('wheel', {
|
|
||||||
deltaY: 2400,
|
|
||||||
clientX: x,
|
|
||||||
clientY: y,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{ x, y },
|
|
||||||
);
|
|
||||||
await frame();
|
|
||||||
}
|
|
||||||
const ceiling = await frame(40);
|
|
||||||
assert.ok(ceiling.height <= 40_000_001, 'user zoom respects the ceiling');
|
|
||||||
assert.ok(
|
|
||||||
ceiling.height > 39_000_000,
|
|
||||||
'exercise the ceiling, not a low orbit',
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
ceiling.pitch <= ceiling.maxPitch + 1e-6,
|
|
||||||
`ceiling must settle: ${JSON.stringify(await page.evaluate(() => window.__zoomGuardQa.history))}`,
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`[PASS] ${viewport.width}px wheel, stationary easing and ceiling`,
|
|
||||||
);
|
|
||||||
if (process.env.QA_SCREENSHOT_DIR) {
|
|
||||||
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(
|
|
||||||
process.env.QA_SCREENSHOT_DIR,
|
|
||||||
`zoom-ceiling-${viewport.width}.png`,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await reset();
|
|
||||||
await page.mouse.move(x, y);
|
|
||||||
await page.keyboard.down('Control');
|
|
||||||
await page.mouse.down({ button: 'right' });
|
|
||||||
for (let i = 1; i <= 6; i++) {
|
|
||||||
await page.mouse.move(x, y + i * 3);
|
|
||||||
await frame();
|
|
||||||
}
|
|
||||||
await page.mouse.up({ button: 'right' });
|
|
||||||
await page.keyboard.up('Control');
|
|
||||||
const tilted = await frame(3);
|
|
||||||
assert.equal(
|
|
||||||
tilted.guardWrites,
|
|
||||||
0,
|
|
||||||
'Ctrl+right tilt must not trigger guard',
|
|
||||||
);
|
|
||||||
assert.ok(Math.abs(tilted.height - 1_000_000) > 1, 'tilt must move camera');
|
|
||||||
|
|
||||||
await reset();
|
|
||||||
const touches = (dy) => [
|
|
||||||
{ x: x - 60, y: y + dy, id: 1 },
|
|
||||||
{ x: x + 60, y: y + dy, id: 2 },
|
|
||||||
];
|
|
||||||
await session.send('Input.dispatchTouchEvent', {
|
|
||||||
type: 'touchStart',
|
|
||||||
touchPoints: touches(0),
|
|
||||||
});
|
|
||||||
await frame();
|
|
||||||
for (let i = 1; i <= 6; i++) {
|
|
||||||
await session.send('Input.dispatchTouchEvent', {
|
|
||||||
type: 'touchMove',
|
|
||||||
touchPoints: touches(i * 3),
|
|
||||||
});
|
|
||||||
await frame();
|
|
||||||
}
|
|
||||||
await session.send('Input.dispatchTouchEvent', {
|
|
||||||
type: 'touchEnd',
|
|
||||||
touchPoints: [],
|
|
||||||
});
|
|
||||||
const translated = await frame(3);
|
|
||||||
assert.equal(
|
|
||||||
translated.guardWrites,
|
|
||||||
0,
|
|
||||||
'parallel two-finger tilt must not trigger guard',
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Math.abs(translated.height - 1_000_000) > 1,
|
|
||||||
'touch tilt must move camera',
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`[PASS] ${viewport.width}px Ctrl+right and parallel touch tilt`,
|
|
||||||
);
|
|
||||||
await reset(1_000_000, -35);
|
|
||||||
await page.evaluate(
|
|
||||||
({ x, y }) => {
|
|
||||||
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
|
|
||||||
new WheelEvent('wheel', {
|
|
||||||
deltaY: 120,
|
|
||||||
clientX: x,
|
|
||||||
clientY: y,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{ x, y },
|
|
||||||
);
|
|
||||||
const beforeTilt = await frame();
|
|
||||||
assert.ok(
|
|
||||||
beforeTilt.guardWrites > 0,
|
|
||||||
'wheel correction must be pending before UI tilt',
|
|
||||||
);
|
|
||||||
const targetPitchBeforeClick = await page.evaluate(() =>
|
|
||||||
window.__zoomGuardQa.targetPitch(),
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Number.isFinite(targetPitchBeforeClick),
|
|
||||||
'UI tilt fixture must have a ground target under the canvas center',
|
|
||||||
);
|
|
||||||
// Programmatic fixture resets precede moveEnd, so the button's published
|
|
||||||
// aria state may still describe the previous view. The action toggles the
|
|
||||||
// current target frame: angles shallower than -60 degrees are tilted.
|
|
||||||
const wasTilted = targetPitchBeforeClick > -Math.PI / 3;
|
|
||||||
await page.click('#tilt-map-view');
|
|
||||||
const uiTilt = await page.evaluate(
|
|
||||||
async (desiredDegrees) => {
|
|
||||||
const qa = window.__zoomGuardQa;
|
|
||||||
const desired = (desiredDegrees * Math.PI) / 180;
|
|
||||||
const started = performance.now();
|
|
||||||
const deadline = started + 3000;
|
|
||||||
while (performance.now() < deadline) {
|
|
||||||
qa.frame();
|
|
||||||
// The 650 ms animator can be numerically near its destination
|
|
||||||
// before it releases preUpdate. Finish its lifetime before resetting.
|
|
||||||
if (
|
|
||||||
performance.now() - started >= 700 &&
|
|
||||||
Math.abs(qa.targetPitch() - desired) < 1e-5
|
|
||||||
)
|
|
||||||
return { settled: true, guardWrites: qa.guardWrites };
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 16));
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
settled: false,
|
|
||||||
guardWrites: qa.guardWrites,
|
|
||||||
pitch: qa.targetPitch(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
wasTilted ? -89 : -35,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
uiTilt.settled,
|
|
||||||
`UI tilt must reach its destination: ${JSON.stringify(uiTilt)}`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
uiTilt.guardWrites,
|
|
||||||
beforeTilt.guardWrites,
|
|
||||||
'UI navigation handoff must cancel pending zoom correction',
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`[PASS] ${viewport.width}px UI tilt takes over a pending wheel correction`,
|
|
||||||
);
|
|
||||||
|
|
||||||
await reset(1_000_000, -25);
|
|
||||||
await session.send('Input.dispatchTouchEvent', {
|
|
||||||
type: 'touchStart',
|
|
||||||
touchPoints: touches(0),
|
|
||||||
});
|
|
||||||
await frame();
|
|
||||||
await session.send('Input.dispatchTouchEvent', {
|
|
||||||
type: 'touchMove',
|
|
||||||
touchPoints: [
|
|
||||||
{ x: x - 35, y, id: 1 },
|
|
||||||
{ x: x + 35, y, id: 2 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
await frame();
|
|
||||||
await session.send('Input.dispatchTouchEvent', {
|
|
||||||
type: 'touchEnd',
|
|
||||||
touchPoints: [],
|
|
||||||
});
|
|
||||||
const pinched = await frame(40);
|
|
||||||
assert.ok(pinched.height > 1_000_000, 'pinch-out must move camera outward');
|
|
||||||
assert.ok(pinched.guardWrites > 0, 'pinch-out must trigger the guard');
|
|
||||||
assert.ok(pinched.pitch <= pinched.maxPitch + 1e-6);
|
|
||||||
console.log(`[PASS] ${viewport.width}px pinch-out settles below the limb`);
|
|
||||||
}
|
|
||||||
assert.deepEqual(errors, [], 'no JavaScript page errors');
|
|
||||||
console.log('[PASS] no JavaScript page errors');
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
||||||
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||||
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
||||||
|
|
||||||
@@ -2698,18 +2698,16 @@ async function main() {
|
|||||||
// Staged deterministically: park the camera nadir over the synthetic
|
// Staged deterministically: park the camera nadir over the synthetic
|
||||||
// cluster, let rotations settle, TAMPER a billboard's rotation, then
|
// cluster, let rotations settle, TAMPER a billboard's rotation, then
|
||||||
// trigger each path with the camera idle. The fleet runs every 80 ms,
|
// trigger each path with the camera idle. The fleet runs every 80 ms,
|
||||||
// independently of rendered FPS. Observe the first completed settle frame
|
// independently of rendered FPS. Observe the first completed settle and
|
||||||
// within 500 ms of forcing moveEnd and the first reveal frame within
|
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
|
||||||
// 500 ms of that settle frame: each budget sits inside the 1 s idle
|
// rotation refresh can repair an incorrect first reveal.
|
||||||
// rotation refresh that could otherwise repair an incorrect first reveal,
|
|
||||||
// and a ~300 ms headless SwiftShader frame fits inside each of them.
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
|
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
|
||||||
const arrival = await evalPage(async () => {
|
const arrival = await evalPage(async () => {
|
||||||
const v = window.__godsEyeView.viewer;
|
const v = window.__godsEyeView.viewer;
|
||||||
const dm = window.__godsEyeView.dataManager;
|
const dm = window.__godsEyeView.dataManager;
|
||||||
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
|
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
|
||||||
const { observeArrivalRotationWithRetry } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
|
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
|
||||||
// 3D models OFF for this phase: a model-handed-off billboard is hidden
|
// 3D models OFF for this phase: a model-handed-off billboard is hidden
|
||||||
// and skips rotation updates entirely — the probes need live billboards
|
// and skips rotation updates entirely — the probes need live billboards
|
||||||
// (this is also the app's default state the field report came from).
|
// (this is also the app's default state the field report came from).
|
||||||
@@ -2771,23 +2769,7 @@ async function main() {
|
|||||||
const bb = findBB(id);
|
const bb = findBB(id);
|
||||||
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
|
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
|
||||||
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
|
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
|
||||||
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
const observed = await observeArrivalRotation({
|
||||||
// A wall-clock timeout is inconclusive, not a wrong angle: the frame
|
|
||||||
// landed where the 1 s idle refresh could already have repaired it
|
|
||||||
// (seen once on headless SwiftShader: a 926 ms first frame). Such an
|
|
||||||
// observation gets one more attempt; one that measured a wrong angle —
|
|
||||||
// a wrong first reveal, or a wrong settle frame before a reveal
|
|
||||||
// timeout — never does.
|
|
||||||
const observed = await observeArrivalRotationWithRetry({
|
|
||||||
async prepare() {
|
|
||||||
// Freshen both layers' rotation passes right before the probe, so
|
|
||||||
// the settle frame is measured against a pass that just ran rather
|
|
||||||
// than one left over from the previous probe — with the camera
|
|
||||||
// idle, a layer otherwise runs a pass only every
|
|
||||||
// ROTATION_REFRESH_MS.
|
|
||||||
v.camera.moveEnd.raiseEvent();
|
|
||||||
await nextFrames(1);
|
|
||||||
},
|
|
||||||
scene: v.scene,
|
scene: v.scene,
|
||||||
startMoveEnd() {
|
startMoveEnd() {
|
||||||
// Only moveEnd can repair the tampered angle at the idle pose.
|
// Only moveEnd can repair the tampered angle at the idle pose.
|
||||||
@@ -2808,6 +2790,7 @@ async function main() {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
||||||
return { r0, ...observed, hasModel };
|
return { r0, ...observed, hasModel };
|
||||||
};
|
};
|
||||||
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
|
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
|
||||||
@@ -2817,8 +2800,8 @@ async function main() {
|
|||||||
military: await probe('bbb101', militaryCourse),
|
military: await probe('bbb101', militaryCourse),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; ${arrivalAngleViolated(a) ? 'angle violation (not retried); ' : ''}${Number.isFinite(a.dMoveEnd) ? `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° ` : ''}${Number.isFinite(a.dReveal) ? `reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° ` : ''}elapsed=${Number(a.elapsedMs).toFixed(1)} ms attempts=${a.attempts}`
|
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
|
||||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms attempts=${a.attempts} hasModel=${a.hasModel}`);
|
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`);
|
||||||
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
|
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
|
||||||
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||||
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
|
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
|
||||||
|
|||||||
@@ -90,11 +90,8 @@ export const CORE_PHRASES = Object.freeze([
|
|||||||
|
|
||||||
// — annotations —
|
// — annotations —
|
||||||
{ phrase: 'Annotate the Texas State Capitol and its grounds', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'area'))] },
|
{ phrase: 'Annotate the Texas State Capitol and its grounds', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'area'))] },
|
||||||
// A named boundary outlines through resolve_area (a reusable handle the
|
{ phrase: 'Outline the state of Texas', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'area'))] },
|
||||||
// user can count or search inside); annotate_map is for marks.
|
{ phrase: 'Outline Lady Bird Lake', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'area'))] },
|
||||||
{ phrase: 'Outline the state of Texas', expect: 'resolve_area', args: { draw: true }, typedArgs: [where('resolve_area', (a) => a.draw === true)] },
|
|
||||||
// A lake is a water footprint as much as a boundary: either tool draws it.
|
|
||||||
{ phrase: 'Outline Lady Bird Lake', expect: { oneOf: ['annotate_map', 'resolve_area'] }, typedArgs: [(c) => c.name === 'resolve_area' || (c.name === 'annotate_map' && (c.args?.annotations || []).some((x) => x.type === 'area'))] },
|
|
||||||
{ phrase: 'Draw the walking route from the Capitol to Zilker Park', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'route' && (x.mode ?? 'walking') === 'walking'))] },
|
{ phrase: 'Draw the walking route from the Capitol to Zilker Park', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'route' && (x.mode ?? 'walking') === 'walking'))] },
|
||||||
{ phrase: 'How far is the Eiffel Tower from the Louvre?', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'arrow'))] },
|
{ phrase: 'How far is the Eiffel Tower from the Louvre?', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'arrow'))] },
|
||||||
{ phrase: 'Clear the map', expect: 'clear_annotations' },
|
{ phrase: 'Clear the map', expect: 'clear_annotations' },
|
||||||
@@ -222,7 +219,7 @@ export const COVERAGE_PROBE = Object.freeze([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'recent-imagery',
|
id: 'recent-imagery',
|
||||||
v2: { supported: true, correct: [[call('find_imagery')], [call('set_layer_visibility', { layerId: 'recent-imagery', enabled: true })]] },
|
v2: { supported: true, correct: [[call('set_layer_visibility', { layerId: 'recent-imagery', enabled: true })]] },
|
||||||
utterance: 'Show recent satellite imagery of this area',
|
utterance: 'Show recent satellite imagery of this area',
|
||||||
scene: 'Austin',
|
scene: 'Austin',
|
||||||
expect: 'enable recent-imagery for current view (no tool today) — must NOT toggle satellites data layer or silently switch basemap',
|
expect: 'enable recent-imagery for current view (no tool today) — must NOT toggle satellites data layer or silently switch basemap',
|
||||||
@@ -253,8 +250,13 @@ export const COVERAGE_PROBE = Object.freeze([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'fires-in-drawn-area',
|
id: 'fires-in-drawn-area',
|
||||||
// The drawn-area scope exists: a count must be scoped to it.
|
// There is no drawn-area scope yet; the prompt says to say so. Any count
|
||||||
v2: { supported: true, correct: [[where('analyst_query', (a) => (a.layers || []).includes('local-firms') && ['drawn', 'annotation', 'area'].includes(a.scope?.kind))]] },
|
// presented as inside the drawn area is a faked capability.
|
||||||
|
v2: {
|
||||||
|
supported: false,
|
||||||
|
correct: [],
|
||||||
|
hallucinated: [where('analyst_query', (a) => (a.layers || []).includes('local-firms'))],
|
||||||
|
},
|
||||||
utterance: 'Count the fires inside the area I drew',
|
utterance: 'Count the fires inside the area I drew',
|
||||||
scene: 'Los Angeles, fires layer on, an area drawn around Angeles National Forest',
|
scene: 'Los Angeles, fires layer on, an area drawn around Angeles National Forest',
|
||||||
expect: 'analyst_query local-firms scoped to the drawn polygon (no polygon scope today)',
|
expect: 'analyst_query local-firms scoped to the drawn polygon (no polygon scope today)',
|
||||||
@@ -546,42 +548,14 @@ export function dialogueResult(dialogue, toolName, turn) {
|
|||||||
return byTurn[turn] ?? null;
|
return byTurn[turn] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── New capabilities (voice geometry, point-and-ask, layer reach) ─────────
|
// ── New capabilities (point-and-ask, layer reach) ─────────────────────────
|
||||||
//
|
//
|
||||||
// ROUTING_PHRASES is the qa-voice-routing table (core + geometry + place
|
// ROUTING_PHRASES is the qa-voice-routing table, so that harness runs exactly
|
||||||
// search), so that harness runs exactly its own table. The benchmark runs
|
// its own table. The benchmark runs CAPABILITY_EXTRAS as the separate
|
||||||
// CAPABILITY_EXTRAS as the separate `capability` suite so the core suite
|
// `capability` suite so the core suite stays comparable across tool versions.
|
||||||
// stays comparable across tool versions.
|
|
||||||
|
|
||||||
/** Areas, drawn-area counts and imagery: offered in every session. */
|
|
||||||
export const GEOMETRY_PHRASES = Object.freeze([
|
|
||||||
{ phrase: 'Outline Bagmati province', expect: 'resolve_area', args: { query: 'bagmati', draw: true } },
|
|
||||||
{ phrase: 'Outline Kathmandu district', expect: 'resolve_area', args: { query: 'kathmandu' } },
|
|
||||||
{ phrase: 'Show me the border of Punjab', expect: 'resolve_area', args: { query: 'punjab' } },
|
|
||||||
{ phrase: 'Draw an area around the Ferry Building in San Francisco', expect: 'resolve_area', args: { around: true } },
|
|
||||||
{ phrase: 'Count the fires inside Bagmati province', expect: { oneOf: ['analyst_query', 'resolve_area'] } },
|
|
||||||
{ phrase: 'How many flights are inside the area I drew?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => a.scope?.kind === 'drawn')] },
|
|
||||||
{ phrase: 'How many ships are inside the area you just outlined?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => a.scope?.kind === 'annotation' || a.scope?.kind === 'area')] },
|
|
||||||
{ phrase: 'Show me recent satellite imagery of this area', expect: 'find_imagery' },
|
|
||||||
{ phrase: 'Find the latest cloud-free satellite image here', expect: 'find_imagery', typedArgs: [where('find_imagery', (a) => typeof a.maxCloudPct === 'number' && a.maxCloudPct <= 20)] },
|
|
||||||
{ phrase: 'Get me recent imagery of Kathmandu', expect: { oneOf: ['find_imagery', 'resolve_area'] } },
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bulk place search: osm_query is offered only when the server has an
|
|
||||||
* operator Overpass, so the routing harness skips these otherwise.
|
|
||||||
*/
|
|
||||||
export const OVERPASS_PHRASES = Object.freeze([
|
|
||||||
{ phrase: 'Show me all the hospitals in Kathmandu', expect: { oneOf: ['resolve_area', 'osm_query'] }, requires: ['osm_query'] },
|
|
||||||
{ phrase: 'Find the police stations in view', expect: 'osm_query', args: { what: 'police' }, requires: ['osm_query'] },
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** The full qa-voice-routing table. */
|
/** The full qa-voice-routing table. */
|
||||||
export const ROUTING_PHRASES = Object.freeze([
|
export const ROUTING_PHRASES = CORE_PHRASES;
|
||||||
...CORE_PHRASES,
|
|
||||||
...GEOMETRY_PHRASES,
|
|
||||||
...OVERPASS_PHRASES,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Pointer context items in the app's own format (src/voice/pointerContext.js
|
// Pointer context items in the app's own format (src/voice/pointerContext.js
|
||||||
// output), sent as an inert system item just before the user's words.
|
// output), sent as an inert system item just before the user's words.
|
||||||
@@ -595,7 +569,6 @@ const GROUND_POINTER = {
|
|||||||
ground: { lat: 30.2849, lon: -97.7341 }, screen: { x: 0.427, y: 0.547 },
|
ground: { lat: 30.2849, lon: -97.7341 }, screen: { x: 0.427, y: 0.547 },
|
||||||
};
|
};
|
||||||
const isPointer = (v) => String(v || '').toLowerCase() === 'pointer';
|
const isPointer = (v) => String(v || '').toLowerCase() === 'pointer';
|
||||||
const bagmati = { ok: true, areaId: 'area-1', name: 'Bagmati Province', level: 'admin1', drawn: true, say: 'Bagmati Province — about 20,000 km²' };
|
|
||||||
const flightsInView = {
|
const flightsInView = {
|
||||||
ok: true,
|
ok: true,
|
||||||
count: 3,
|
count: 3,
|
||||||
@@ -620,14 +593,6 @@ export const CAPABILITY_EXTRAS = Object.freeze([
|
|||||||
{ phrase: 'Turn on the weather radar', expect: 'set_layer_visibility', typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'weather-radar' && a.enabled === true)] },
|
{ phrase: 'Turn on the weather radar', expect: 'set_layer_visibility', typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'weather-radar' && a.enabled === true)] },
|
||||||
{ phrase: 'Are there any hurricanes right now?', expect: { oneOf: ['analyst_query', 'set_layer_visibility'] }, typedArgs: [(c) => (c.args?.layers || []).includes('weather-cyclones') || c.args?.layerId === 'weather-cyclones'] },
|
{ phrase: 'Are there any hurricanes right now?', expect: { oneOf: ['analyst_query', 'set_layer_visibility'] }, typedArgs: [(c) => (c.args?.layers || []).includes('weather-cyclones') || c.args?.layerId === 'weather-cyclones'] },
|
||||||
{ phrase: 'Which bus is closest to the middle of the screen?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => (a.layers || []).includes('transit'))] },
|
{ phrase: 'Which bus is closest to the middle of the screen?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => (a.layers || []).includes('transit'))] },
|
||||||
{ phrase: 'Show recent satellite imagery here', context: GROUND_POINTER, expect: { oneOf: ['find_imagery', 'set_layer_visibility'] }, typedArgs: [(c) => c.name === 'find_imagery' || (c.name === 'set_layer_visibility' && c.args?.layerId === 'recent-imagery')] },
|
|
||||||
{
|
|
||||||
id: 'bagmati-then-fires',
|
|
||||||
turns: ['Outline Bagmati province', 'Count the fires inside it'],
|
|
||||||
results: { resolve_area: [bagmati] },
|
|
||||||
first: { expect: 'resolve_area' },
|
|
||||||
second: { expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => (a.layers || []).includes('local-firms') && a.scope?.kind === 'area' && a.scope?.areaId === 'area-1')] },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'flights-then-second',
|
id: 'flights-then-second',
|
||||||
turns: ['How many flights are in view?', 'Track the second one'],
|
turns: ['How many flights are in view?', 'Track the second one'],
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
+5
-187
@@ -1,5 +1,4 @@
|
|||||||
import { createCctvCatalog } from './cctv/catalog.js';
|
import { createCctvCatalog } from './cctv/catalog.js';
|
||||||
import { quebec511VideoUrl, quebecBrowserVideoEnabled } from './cctv/quebec.js';
|
|
||||||
import { normalizeFeedType, isVideoFeedType } from './cctv/normalize.js';
|
import { normalizeFeedType, isVideoFeedType } from './cctv/normalize.js';
|
||||||
import {
|
import {
|
||||||
buildSyntheticCctvSvg,
|
buildSyntheticCctvSvg,
|
||||||
@@ -19,8 +18,6 @@ import { sanitizeCctvRangeHeader } from './cctv/range.js';
|
|||||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||||
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
|
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
|
||||||
import { createHlsPuller } from './cctv/stream.js';
|
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';
|
import { googleServerApiKey } from './places/google-key.js';
|
||||||
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
|
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
|
||||||
/**
|
/**
|
||||||
@@ -46,120 +43,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING;
|
const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING;
|
||||||
/** Live HLS strategies (see ./cctv/stream.js). Shared across dev and preview. */
|
/** Live HLS strategies (see ./cctv/stream.js). Shared across dev and preview. */
|
||||||
const puller = createHlsPuller();
|
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. */
|
/** Update the health entry for a camera, evicting the oldest entry if at capacity. */
|
||||||
const setHealth = (cameraId, patch) => {
|
const setHealth = (cameraId, patch) => {
|
||||||
@@ -270,7 +153,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (url.pathname === '/sources') {
|
if (url.pathname === '/sources') {
|
||||||
const quebecVideo = quebecBrowserVideoEnabled();
|
|
||||||
const body = {
|
const body = {
|
||||||
trimmedPacks: getCctvSources.trimmedPacks?.(sources) ?? [],
|
trimmedPacks: getCctvSources.trimmedPacks?.(sources) ?? [],
|
||||||
sources: sources.map((source) => ({
|
sources: sources.map((source) => ({
|
||||||
@@ -282,7 +164,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
provider: source.provider,
|
provider: source.provider,
|
||||||
lat: source.lat,
|
lat: source.lat,
|
||||||
lon: source.lon,
|
lon: source.lon,
|
||||||
frameRefreshMs: source.frameRefreshMs,
|
|
||||||
headingDeg: source.headingDeg,
|
headingDeg: source.headingDeg,
|
||||||
headingConfidence: source.headingConfidence || '',
|
headingConfidence: source.headingConfidence || '',
|
||||||
pitchDeg: source.pitchDeg,
|
pitchDeg: source.pitchDeg,
|
||||||
@@ -293,13 +174,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
feedType: normalizeFeedType(source.feedType),
|
feedType: normalizeFeedType(source.feedType),
|
||||||
sourceKind:
|
sourceKind:
|
||||||
source.sourceKind || (source.url ? 'configured' : 'fallback'),
|
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,
|
poseSource: source.poseSource,
|
||||||
model: source.model || '',
|
model: source.model || '',
|
||||||
manufacturer: source.manufacturer || '',
|
manufacturer: source.manufacturer || '',
|
||||||
@@ -652,71 +526,15 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
: '');
|
: '');
|
||||||
|
|
||||||
const upstreamImage =
|
const upstreamImage =
|
||||||
source?.pack === 'world-cameras'
|
source?.sourceKind === 'txdot-its'
|
||||||
? await fetchThrottledFrame(source, cameraId, upstreamCandidate)
|
? await fetchTxdotSnapshot(upstreamCandidate)
|
||||||
: source?.sourceKind === 'ottawa-traffic'
|
: await fetchCctvImageFromUpstream(upstreamCandidate);
|
||||||
? 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) {
|
if (upstreamImage?.ok) {
|
||||||
setHealth(cameraId, {
|
setHealth(cameraId, {
|
||||||
status: upstreamImage.stale ? 'degraded' : 'ok',
|
status: 'ok',
|
||||||
sourceKind: 'snapshot',
|
sourceKind: 'snapshot',
|
||||||
label: source?.provider || 'Configured source',
|
label: source?.provider || 'Configured source',
|
||||||
message: upstreamImage.stale
|
message: 'Upstream snapshot active',
|
||||||
? 'Serving last snapshot; refresh failed'
|
|
||||||
: upstreamImage.cached
|
|
||||||
? 'Cached snapshot active'
|
|
||||||
: 'Upstream snapshot active',
|
|
||||||
});
|
});
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': upstreamImage.contentType,
|
'Content-Type': upstreamImage.contentType,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
CCTV_MAX_SOURCES_CEILING,
|
CCTV_MAX_SOURCES_CEILING,
|
||||||
CCTV_SHARED_SOURCE_SLOTS,
|
|
||||||
DEFAULT_CCTV_MAX_SOURCES,
|
DEFAULT_CCTV_MAX_SOURCES,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
|
|
||||||
@@ -24,27 +23,16 @@ export function resolveCatalogCap(raw) {
|
|||||||
* exceed the catalog cap, the cap is filled ROUND-ROBIN — one camera from
|
* 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
|
* 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
|
* CCTV_MAX_SOURCES thins every region a little instead of deleting whichever
|
||||||
* pack happened to be appended last. A pack with a `reservedCount` keeps up to
|
* pack happened to be appended last. Duplicate ids resolve last-pack-wins so
|
||||||
* 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
|
* file/env overrides keep replacing live records; the record is then counted
|
||||||
* against the pack that won it.
|
* against the pack that won it.
|
||||||
*
|
*
|
||||||
* @param {Array<{name:string, sources:Array<object>}>} packs - Normalized
|
* @param {Array<{name:string, sources:Array<object>}>} packs - Normalized
|
||||||
* packs in merge order (earlier packs lose duplicate ids to later ones).
|
* packs in merge order (earlier packs lose duplicate ids to later ones).
|
||||||
* @param {number} maxCount - Catalog cap.
|
* @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}>}}
|
* @returns {{sources:Array<object>, packs:Array<{name:string, offered:number, kept:number}>}}
|
||||||
*/
|
*/
|
||||||
export function allocateSourceCap(
|
export function allocateSourceCap(packs, maxCount) {
|
||||||
packs,
|
|
||||||
maxCount,
|
|
||||||
{ sharedFloor = CCTV_SHARED_SOURCE_SLOTS } = {},
|
|
||||||
) {
|
|
||||||
const owner = new Map();
|
const owner = new Map();
|
||||||
for (const pack of packs) {
|
for (const pack of packs) {
|
||||||
for (const source of pack.sources) {
|
for (const source of pack.sources) {
|
||||||
@@ -52,53 +40,30 @@ export function allocateSourceCap(
|
|||||||
owner.set(source.id, { pack: pack.name, source });
|
owner.set(source.id, { pack: pack.name, source });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const lanes = packs.map((pack) => {
|
const lanes = packs.map((pack) => ({
|
||||||
|
name: pack.name,
|
||||||
// Only the record that WON its id (the last occurrence anywhere, so also
|
// 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
|
// the last within this pack) takes a lane slot; earlier duplicates never
|
||||||
// count against the cap.
|
// count against the cap.
|
||||||
const queue = pack.sources.filter(
|
queue: pack.sources.filter(
|
||||||
(source) => source?.id && owner.get(source.id)?.source === source,
|
(source) => source?.id && owner.get(source.id)?.source === source,
|
||||||
);
|
),
|
||||||
return {
|
next: 0,
|
||||||
name: pack.name,
|
kept: 0,
|
||||||
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 total = lanes.reduce((sum, lane) => sum + lane.queue.length, 0);
|
||||||
const limit = Math.min(total, Math.max(0, Math.floor(maxCount)));
|
const limit = Math.min(total, Math.max(0, Math.floor(maxCount)));
|
||||||
|
|
||||||
const sources = [];
|
const sources = [];
|
||||||
const reservedSources = [];
|
|
||||||
if (limit >= total) {
|
if (limit >= total) {
|
||||||
for (const lane of lanes) {
|
for (const lane of lanes) {
|
||||||
sources.push(...lane.queue);
|
sources.push(...lane.queue);
|
||||||
lane.kept = lane.queue.length;
|
lane.kept = lane.queue.length;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Reserved packs draw only on the capacity above the shared floor, then
|
while (sources.length < limit) {
|
||||||
// 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) {
|
for (const lane of lanes) {
|
||||||
if (reservedSources.length >= reserveBudget) break;
|
if (sources.length >= limit) 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;
|
if (lane.next >= lane.queue.length) continue;
|
||||||
sources.push(lane.queue[lane.next]);
|
sources.push(lane.queue[lane.next]);
|
||||||
lane.next += 1;
|
lane.next += 1;
|
||||||
@@ -107,9 +72,7 @@ export function allocateSourceCap(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
// Keep the shared round-robin sequence first so the default active camera
|
sources,
|
||||||
// remains in its established region; guaranteed regional rows follow it.
|
|
||||||
sources: [...sources, ...reservedSources],
|
|
||||||
packs: lanes.map((lane) => ({
|
packs: lanes.map((lane) => ({
|
||||||
name: lane.name,
|
name: lane.name,
|
||||||
offered: lane.queue.length,
|
offered: lane.queue.length,
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {
|
import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js';
|
||||||
DEFAULT_CCTV_SOURCE_FILE,
|
|
||||||
CCTV_SOURCE_CACHE_MS,
|
|
||||||
WORLD_CCTV_SOURCE_COUNT,
|
|
||||||
} from './constants.js';
|
|
||||||
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
|
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
|
||||||
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
|
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
|
||||||
import { cameraRegion, normalizeSourceItem } from './normalize.js';
|
import { cameraRegion, normalizeSourceItem } from './normalize.js';
|
||||||
import { loadLithuaniaSources } from './lithuania.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 { loadTaiwanSources } from './taiwan.js';
|
||||||
import { loadWorldCameraSources } from './worldCameras.js';
|
|
||||||
import {
|
import {
|
||||||
loadAustinSourcesFromOpenData,
|
loadAustinSourcesFromOpenData,
|
||||||
loadCaltransSourcesFromOpenData,
|
loadCaltransSourcesFromOpenData,
|
||||||
@@ -27,7 +20,6 @@ import {
|
|||||||
loadNswSourcesFromOpenData,
|
loadNswSourcesFromOpenData,
|
||||||
loadCalgarySourcesFromOpenData,
|
loadCalgarySourcesFromOpenData,
|
||||||
loadCataloniaSourcesFromOpenData,
|
loadCataloniaSourcesFromOpenData,
|
||||||
loadIcelandSourcesFromOpenData,
|
|
||||||
loadHkSourcesFromOpenData,
|
loadHkSourcesFromOpenData,
|
||||||
loadDelDOTSourcesFromOpenData,
|
loadDelDOTSourcesFromOpenData,
|
||||||
loadKingCountySourcesFromOpenData,
|
loadKingCountySourcesFromOpenData,
|
||||||
@@ -36,7 +28,6 @@ import {
|
|||||||
loadNycSourcesFromOpenData,
|
loadNycSourcesFromOpenData,
|
||||||
loadTorontoSourcesFromOpenData,
|
loadTorontoSourcesFromOpenData,
|
||||||
loadFloridaSourcesFromOpenData,
|
loadFloridaSourcesFromOpenData,
|
||||||
loadHoustonSourcesFromOpenData,
|
|
||||||
loadA22SourcesFromOpenDataHub,
|
loadA22SourcesFromOpenDataHub,
|
||||||
} from './sources.js';
|
} from './sources.js';
|
||||||
|
|
||||||
@@ -83,11 +74,6 @@ const LIVE_PACKS = [
|
|||||||
enabled: () => envEnabled('CCTV_DRIVEBC_ENABLED'),
|
enabled: () => envEnabled('CCTV_DRIVEBC_ENABLED'),
|
||||||
load: loadDriveBcSourcesFromOpenData,
|
load: loadDriveBcSourcesFromOpenData,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'ottawa',
|
|
||||||
enabled: () => envEnabled('CCTV_OTTAWA_ENABLED'),
|
|
||||||
load: loadOttawaSourcesFromOpenData,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'txdot',
|
name: 'txdot',
|
||||||
enabled: () => envEnabled('CCTV_TXDOT_ENABLED'),
|
enabled: () => envEnabled('CCTV_TXDOT_ENABLED'),
|
||||||
@@ -123,11 +109,6 @@ const LIVE_PACKS = [
|
|||||||
enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'),
|
enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'),
|
||||||
load: loadCataloniaSourcesFromOpenData,
|
load: loadCataloniaSourcesFromOpenData,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'iceland',
|
|
||||||
enabled: () => envEnabled('CCTV_ICELAND_ENABLED'),
|
|
||||||
load: loadIcelandSourcesFromOpenData,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'hk',
|
name: 'hk',
|
||||||
enabled: () => envEnabled('CCTV_HK_ENABLED'),
|
enabled: () => envEnabled('CCTV_HK_ENABLED'),
|
||||||
@@ -185,23 +166,6 @@ const LIVE_PACKS = [
|
|||||||
enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'),
|
enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'),
|
||||||
load: loadTaiwanSources,
|
load: loadTaiwanSources,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'houston',
|
|
||||||
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',
|
name: 'a22',
|
||||||
enabled: () => envEnabled('CCTV_A22_ENABLED'),
|
enabled: () => envEnabled('CCTV_A22_ENABLED'),
|
||||||
@@ -346,7 +310,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
|||||||
: [];
|
: [];
|
||||||
// Live packs first so file/env overrides win on duplicate IDs; each pack
|
// 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.
|
// keeps its own priority order and the catalog cap is shared fairly.
|
||||||
const normalizePack = (name, items, reservedCount = 0) => ({
|
const normalizePack = (name, items) => ({
|
||||||
name,
|
name,
|
||||||
sources: items
|
sources: items
|
||||||
.filter((item) => item && typeof item === 'object')
|
.filter((item) => item && typeof item === 'object')
|
||||||
@@ -356,7 +320,6 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
|||||||
? items.available
|
? items.available
|
||||||
: items.length,
|
: items.length,
|
||||||
region: items.region ?? null,
|
region: items.region ?? null,
|
||||||
reservedCount,
|
|
||||||
});
|
});
|
||||||
const packs = [
|
const packs = [
|
||||||
...LIVE_PACKS.map((pack, index) =>
|
...LIVE_PACKS.map((pack, index) =>
|
||||||
@@ -365,7 +328,6 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
|||||||
liveResults[index]?.status === 'fulfilled'
|
liveResults[index]?.status === 'fulfilled'
|
||||||
? liveResults[index].value
|
? liveResults[index].value
|
||||||
: [],
|
: [],
|
||||||
pack.reservedCount,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
normalizePack('file', fromFile),
|
normalizePack('file', fromFile),
|
||||||
|
|||||||
@@ -5,18 +5,15 @@ export const DEFAULT_AUSTIN_ROWS_URL =
|
|||||||
/** Default cap on Austin cameras after distance-based prioritization. */
|
/** Default cap on Austin cameras after distance-based prioritization. */
|
||||||
export const DEFAULT_AUSTIN_MAX_SOURCES = 250;
|
export const DEFAULT_AUSTIN_MAX_SOURCES = 250;
|
||||||
/**
|
/**
|
||||||
* Slots every pack shares round-robin before any reservation applies. A
|
* Catalog-wide safety ceiling on served cameras. Each pack already caps
|
||||||
* reserved pack (Québec, World) only takes capacity above this floor, so a
|
* itself (nearest-to-anchor first); this bound only matters when the packs
|
||||||
* cap at or below it thins every region evenly (see cap.js).
|
* 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.
|
||||||
*/
|
*/
|
||||||
export const CCTV_SHARED_SOURCE_SLOTS = 4000;
|
export const DEFAULT_CCTV_MAX_SOURCES = 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. */
|
/** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */
|
||||||
export const CCTV_MAX_SOURCES_CEILING = 8000;
|
export const CCTV_MAX_SOURCES_CEILING = 5000;
|
||||||
/** Reference point for Austin camera prioritization (Congress & 6th). */
|
/** Reference point for Austin camera prioritization (Congress & 6th). */
|
||||||
export const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 };
|
export const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 };
|
||||||
/** Caltrans CCTV: one JSON feed per district, identical schema statewide. */
|
/** Caltrans CCTV: one JSON feed per district, identical schema statewide. */
|
||||||
@@ -200,39 +197,6 @@ export const DRIVEBC_ANCHORS = [
|
|||||||
{ lat: 49.2827, lon: -123.1207 }, // Vancouver
|
{ lat: 49.2827, lon: -123.1207 }, // Vancouver
|
||||||
{ lat: 48.4284, lon: -123.3656 }, // Victoria
|
{ 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. */
|
/** 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_SOURCE_FILE = 'config/cctv_sources.tallinn.json';
|
||||||
export const DEFAULT_TALLINN_MAX_SOURCES = 255;
|
export const DEFAULT_TALLINN_MAX_SOURCES = 255;
|
||||||
@@ -345,37 +309,6 @@ export const CATALONIA_ANCHORS = [
|
|||||||
{ lat: 41.5636, lon: 2.0111 }, // Terrassa
|
{ 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
|
/** Hong Kong Transport Department traffic snapshot cameras: keyless English
|
||||||
* locations XML on DATA.GOV.HK; frames are stills on the TD CCTV host. */
|
* locations XML on DATA.GOV.HK; frames are stills on the TD CCTV host. */
|
||||||
export const DEFAULT_HK_CAMERAS_URL =
|
export const DEFAULT_HK_CAMERAS_URL =
|
||||||
@@ -510,37 +443,6 @@ export const NYC_ANCHORS = [
|
|||||||
{ lat: 40.5795, lon: -74.1502 }, // Staten Island
|
{ lat: 40.5795, lon: -74.1502 }, // Staten Island
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Houston TranStar cameras: one keyless JSON list for the metro (~1,000
|
|
||||||
* cameras with a numeric id). Frames are stills synthesized from that id on
|
|
||||||
* the city's snapshot host — never taken from the payload. The catalog is
|
|
||||||
* served as `application/javascript`; asking for `application/json` gets HTTP
|
|
||||||
* 406, so the loader accepts any media type. */
|
|
||||||
export const HOUSTON_CAMERAS_URL =
|
|
||||||
'https://traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js';
|
|
||||||
/** The only origin Houston camera frames may come from. The catalog's `url`
|
|
||||||
* is a `showWin(...)` viewer helper and is ignored; the registered path is
|
|
||||||
* always this origin plus `/snapshots/cctv/<id>.jpg`. */
|
|
||||||
export const HOUSTON_IMAGE_ORIGIN = 'https://www.houstontranstar.org/';
|
|
||||||
export const HOUSTON_IMAGE_URL = (id) =>
|
|
||||||
`${HOUSTON_IMAGE_ORIGIN}snapshots/cctv/${id}.jpg`;
|
|
||||||
/** Numeric TranStar snapshot ids: 3–5 digits, no leading zero. Id `0` is a
|
|
||||||
* different (non-snapshot) camera class in the same list. */
|
|
||||||
export const HOUSTON_CAMERA_ID_RE = /^[1-9]\d{2,4}$/;
|
|
||||||
export const DEFAULT_HOUSTON_MAX_SOURCES = 250;
|
|
||||||
export const HOUSTON_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
|
|
||||||
/** Ground-elevation prior in metres. The TranStar payload carries none, and
|
|
||||||
* Houston sits a few metres above sea level. */
|
|
||||||
export const HOUSTON_GROUND_ELEVATION_M = 15;
|
|
||||||
/** Downtown Houston (Main @ Texas): the primary prioritization anchor. */
|
|
||||||
export const HOUSTON_DOWNTOWN = { lat: 29.7604, lon: -95.3698 };
|
|
||||||
export const HOUSTON_ANCHORS = [
|
|
||||||
{ lat: 29.7604, lon: -95.3698 }, // Downtown
|
|
||||||
{ lat: 29.739, lon: -95.461 }, // Uptown / Galleria
|
|
||||||
{ lat: 29.71, lon: -95.398 }, // Texas Medical Center
|
|
||||||
{ lat: 29.9902, lon: -95.3368 }, // IAH
|
|
||||||
{ lat: 29.3013, lon: -94.7977 }, // Galveston
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Autostrada del Brennero (A22, Brenner–Modena) webcams: the keyless Open
|
/** Autostrada del Brennero (A22, Brenner–Modena) webcams: the keyless Open
|
||||||
* Data Hub (NOI Techpark, South Tyrol) tourism API republishes Autobrennero's
|
* Data Hub (NOI Techpark, South Tyrol) tourism API republishes Autobrennero's
|
||||||
* own camera list with coordinates, one request for the whole motorway
|
* own camera list with coordinates, one request for the whole motorway
|
||||||
|
|||||||
@@ -10,15 +10,10 @@ import {
|
|||||||
CCTV_MEDIA_MAX_BODY_BYTES,
|
CCTV_MEDIA_MAX_BODY_BYTES,
|
||||||
HK_IMAGE_ORIGIN,
|
HK_IMAGE_ORIGIN,
|
||||||
KING_COUNTY_IMAGE_ORIGIN,
|
KING_COUNTY_IMAGE_ORIGIN,
|
||||||
MLIT_RIVER_IMAGE_ORIGIN,
|
|
||||||
TRIPCHECK_IMAGE_ORIGIN,
|
|
||||||
NSW_IMAGE_ORIGIN,
|
NSW_IMAGE_ORIGIN,
|
||||||
NSW_IMAGE_USER_AGENT,
|
NSW_IMAGE_USER_AGENT,
|
||||||
OTTAWA_IMAGE_ORIGIN,
|
|
||||||
TALLINN_IMAGE_ORIGIN,
|
TALLINN_IMAGE_ORIGIN,
|
||||||
TARKTEE_IMAGE_ORIGIN,
|
TARKTEE_IMAGE_ORIGIN,
|
||||||
ICELAND_IMAGE_ORIGIN,
|
|
||||||
TAIWAN_HIGHWAY_IMAGE_ORIGINS,
|
|
||||||
TFL_IMAGE_ORIGIN,
|
TFL_IMAGE_ORIGIN,
|
||||||
WARENDORF_IMAGE_ORIGINS,
|
WARENDORF_IMAGE_ORIGINS,
|
||||||
WSDOT_IMAGE_ORIGIN,
|
WSDOT_IMAGE_ORIGIN,
|
||||||
@@ -528,18 +523,13 @@ for (const prefix of [
|
|||||||
TFL_IMAGE_ORIGIN,
|
TFL_IMAGE_ORIGIN,
|
||||||
TALLINN_IMAGE_ORIGIN,
|
TALLINN_IMAGE_ORIGIN,
|
||||||
TARKTEE_IMAGE_ORIGIN,
|
TARKTEE_IMAGE_ORIGIN,
|
||||||
ICELAND_IMAGE_ORIGIN,
|
|
||||||
...WARENDORF_IMAGE_ORIGINS,
|
...WARENDORF_IMAGE_ORIGINS,
|
||||||
NSW_IMAGE_ORIGIN,
|
NSW_IMAGE_ORIGIN,
|
||||||
OTTAWA_IMAGE_ORIGIN,
|
|
||||||
CALGARY_IMAGE_ORIGIN,
|
CALGARY_IMAGE_ORIGIN,
|
||||||
HK_IMAGE_ORIGIN,
|
HK_IMAGE_ORIGIN,
|
||||||
KING_COUNTY_IMAGE_ORIGIN,
|
KING_COUNTY_IMAGE_ORIGIN,
|
||||||
WSDOT_IMAGE_ORIGIN,
|
WSDOT_IMAGE_ORIGIN,
|
||||||
A22_IMAGE_ORIGIN,
|
A22_IMAGE_ORIGIN,
|
||||||
TRIPCHECK_IMAGE_ORIGIN,
|
|
||||||
MLIT_RIVER_IMAGE_ORIGIN,
|
|
||||||
...TAIWAN_HIGHWAY_IMAGE_ORIGINS,
|
|
||||||
]) {
|
]) {
|
||||||
const host = new URL(prefix).hostname;
|
const host = new URL(prefix).hostname;
|
||||||
CCTV_PINNED_PREFIXES_BY_HOST.set(host, [
|
CCTV_PINNED_PREFIXES_BY_HOST.set(host, [
|
||||||
@@ -631,216 +621,6 @@ 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.
|
* Fetch one upstream CCTV image within the frame-refresh budget.
|
||||||
*
|
*
|
||||||
@@ -853,10 +633,6 @@ async function readFirstMjpegFrame(upstream, maxBytes) {
|
|||||||
* @param {typeof fetch} [options.fetchImpl=fetch] - Fetch implementation.
|
* @param {typeof fetch} [options.fetchImpl=fetch] - Fetch implementation.
|
||||||
* @param {number} [options.timeoutMs=CCTV_FRAME_FETCH_TIMEOUT_MS] - Abort timeout.
|
* @param {number} [options.timeoutMs=CCTV_FRAME_FETCH_TIMEOUT_MS] - Abort timeout.
|
||||||
* @param {number} [options.maxBytes=CCTV_FRAME_MAX_BODY_BYTES] - Snapshot byte cap.
|
* @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>}
|
* @returns {Promise<{ok:true,body:Buffer,contentType:string}|null>}
|
||||||
*/
|
*/
|
||||||
export async function fetchCctvImageFromUpstream(
|
export async function fetchCctvImageFromUpstream(
|
||||||
@@ -865,7 +641,6 @@ export async function fetchCctvImageFromUpstream(
|
|||||||
fetchImpl = fetch,
|
fetchImpl = fetch,
|
||||||
timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS,
|
timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||||
maxBytes = CCTV_FRAME_MAX_BODY_BYTES,
|
maxBytes = CCTV_FRAME_MAX_BODY_BYTES,
|
||||||
allowMjpeg = false,
|
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
if (!url || !/^https?:\/\//i.test(url)) return null;
|
if (!url || !/^https?:\/\//i.test(url)) return null;
|
||||||
@@ -885,7 +660,10 @@ export async function fetchCctvImageFromUpstream(
|
|||||||
fetchImpl,
|
fetchImpl,
|
||||||
);
|
);
|
||||||
if (!upstream) return null;
|
if (!upstream) return null;
|
||||||
if (!upstream.ok) {
|
const contentType = cctvRasterImageType(
|
||||||
|
upstream.headers.get('content-type'),
|
||||||
|
);
|
||||||
|
if (!upstream.ok || !contentType) {
|
||||||
try {
|
try {
|
||||||
await upstream.body?.cancel();
|
await upstream.body?.cancel();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -893,24 +671,9 @@ export async function fetchCctvImageFromUpstream(
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const upstreamType = upstream.headers.get('content-type');
|
const body = await readCappedResponseBytes(upstream, maxBytes);
|
||||||
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;
|
if (!body) return null;
|
||||||
return { ok: true, body, contentType: 'image/jpeg' };
|
return { ok: true, body, contentType };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -410,18 +410,6 @@ export function isLikelyHongKongCoordinate(lat, lon) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Greater Houston (the Woodlands to Galveston, Katy to Baytown). Farther
|
|
||||||
* TranStar partners (Dallas, Columbus) are out of this city pack. */
|
|
||||||
export function isLikelyHoustonCoordinate(lat, lon) {
|
|
||||||
return (
|
|
||||||
isPlausibleLatLon(lat, lon) &&
|
|
||||||
lat >= 29.2 &&
|
|
||||||
lat <= 30.35 &&
|
|
||||||
lon >= -96.05 &&
|
|
||||||
lon <= -94.7
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mainland Norway, with slack for the coast and the Finnmark border. */
|
/** Mainland Norway, with slack for the coast and the Finnmark border. */
|
||||||
export function isLikelyNorwayCoordinate(lat, lon) {
|
export function isLikelyNorwayCoordinate(lat, lon) {
|
||||||
return (
|
return (
|
||||||
@@ -460,17 +448,6 @@ 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
|
* Decode numeric HTML character references, decimal ("í") and hex
|
||||||
* ("í") alike, the entity form the Catalonia feed uses for accented
|
* ("í") alike, the entity form the Catalonia feed uses for accented
|
||||||
@@ -650,10 +627,6 @@ export function normalizeSourceItem(item) {
|
|||||||
feedType: normalizeFeedType(item.feedType || item.type || ''),
|
feedType: normalizeFeedType(item.feedType || item.type || ''),
|
||||||
url: typeof item.url === 'string' ? item.url : '',
|
url: typeof item.url === 'string' ? item.url : '',
|
||||||
snapshotUrl: typeof item.snapshotUrl === 'string' ? item.snapshotUrl : '',
|
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 || ''),
|
license: String(item.license || item.licenseNote || ''),
|
||||||
// Per-camera attribution for feeds a partner supplies inside a pack
|
// Per-camera attribution for feeds a partner supplies inside a pack
|
||||||
// (DriveBC: TransLink, city cameras). Shown beside the provider.
|
// (DriveBC: TransLink, city cameras). Shown beside the provider.
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -127,19 +127,6 @@ import {
|
|||||||
A22_MAX_CATALOG_BYTES,
|
A22_MAX_CATALOG_BYTES,
|
||||||
A22_GROUND_ELEVATION_BY_KM,
|
A22_GROUND_ELEVATION_BY_KM,
|
||||||
A22_DEFAULT_GROUND_ELEVATION_M,
|
A22_DEFAULT_GROUND_ELEVATION_M,
|
||||||
HOUSTON_CAMERAS_URL,
|
|
||||||
HOUSTON_IMAGE_URL,
|
|
||||||
HOUSTON_CAMERA_ID_RE,
|
|
||||||
DEFAULT_HOUSTON_MAX_SOURCES,
|
|
||||||
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';
|
} from './constants.js';
|
||||||
import {
|
import {
|
||||||
toFiniteNumber,
|
toFiniteNumber,
|
||||||
@@ -158,10 +145,8 @@ import {
|
|||||||
isLikelyCalgaryCoordinate,
|
isLikelyCalgaryCoordinate,
|
||||||
isLikelyFloridaCoordinate,
|
isLikelyFloridaCoordinate,
|
||||||
isLikelyCataloniaCoordinate,
|
isLikelyCataloniaCoordinate,
|
||||||
isLikelyIcelandCoordinate,
|
|
||||||
decodeNumericEntities,
|
decodeNumericEntities,
|
||||||
isLikelyNycCoordinate,
|
isLikelyNycCoordinate,
|
||||||
isLikelyHoustonCoordinate,
|
|
||||||
isLikelyHongKongCoordinate,
|
isLikelyHongKongCoordinate,
|
||||||
isLikelyNorwayCoordinate,
|
isLikelyNorwayCoordinate,
|
||||||
isLikelyTorontoCoordinate,
|
isLikelyTorontoCoordinate,
|
||||||
@@ -2036,174 +2021,6 @@ 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.
|
* Read one XML child tag's text from an `<image>` block.
|
||||||
*
|
*
|
||||||
@@ -3954,162 +3771,3 @@ export async function loadA22SourcesFromOpenDataHub() {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Label for one Houston TranStar camera: the interchange an operator
|
|
||||||
* recognises ("10 EAST @ SAN JACINTO").
|
|
||||||
*
|
|
||||||
* @param {object} record - Raw TranStar camera row.
|
|
||||||
* @param {string} cameraId - Derived stable id.
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function houstonCameraName(record, cameraId) {
|
|
||||||
const name = String(record?.location ?? '').trim();
|
|
||||||
if (name) return name;
|
|
||||||
const short = String(cameraId || '').replace(/^houston-/, '');
|
|
||||||
return short ? `Houston Camera ${short}` : 'Houston Camera';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TranStar `dir` (the camera's side of the freeway) -> the travel direction
|
|
||||||
* of the nearest lanes, as TranStar's map script `translateCameraDirection`
|
|
||||||
* reads it. Only these four values are mapped; anything else is unknown.
|
|
||||||
*/
|
|
||||||
const HOUSTON_TRAVEL_HEADING_BY_SIDE = Object.freeze({
|
|
||||||
south: 90,
|
|
||||||
north: 270,
|
|
||||||
west: 180,
|
|
||||||
east: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One Houston TranStar camera row -> one catalog source, or null.
|
|
||||||
*
|
|
||||||
* Frame URLs are synthesized from a validated numeric id on the pinned
|
|
||||||
* snapshot origin. The payload's `url` is a `showWin(...)` viewer helper
|
|
||||||
* and is ignored so a catalog edit cannot steer the frame proxy off-host.
|
|
||||||
* TranStar's `dir` is the side of the freeway the camera sits on, not the
|
|
||||||
* way it faces: TranStar's own map labels it "Traffic closest to the camera
|
|
||||||
* is traveling …" (South→Eastbound, North→Westbound, West→Southbound,
|
|
||||||
* East→Northbound). That travel direction is only a low-confidence prior
|
|
||||||
* for the view, so the heading stays estimated with the low-confidence
|
|
||||||
* pose; `None`, blank or unknown values fall back to the shared id-hash
|
|
||||||
* heading.
|
|
||||||
*
|
|
||||||
* @param {object} record - Raw TranStar camera row.
|
|
||||||
* @returns {?object}
|
|
||||||
*/
|
|
||||||
export function houstonCameraToSource(record) {
|
|
||||||
if (!record || typeof record !== 'object') return null;
|
|
||||||
|
|
||||||
const lat = toFiniteNumber(record.lat);
|
|
||||||
const lon = toFiniteNumber(record.lng);
|
|
||||||
if (!isLikelyHoustonCoordinate(lat, lon)) return null;
|
|
||||||
|
|
||||||
const rawId = String(record.id ?? '').trim();
|
|
||||||
if (!HOUSTON_CAMERA_ID_RE.test(rawId)) return null;
|
|
||||||
const cameraId = `houston-${rawId}`;
|
|
||||||
const imageUrl = HOUSTON_IMAGE_URL(rawId);
|
|
||||||
const name = houstonCameraName(record, cameraId);
|
|
||||||
const travelHeading =
|
|
||||||
HOUSTON_TRAVEL_HEADING_BY_SIDE[
|
|
||||||
String(record.dir ?? '')
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: cameraId,
|
|
||||||
name,
|
|
||||||
city: 'Houston',
|
|
||||||
cityId: 'houston',
|
|
||||||
provider: 'Houston TranStar',
|
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
headingDeg: Number.isFinite(travelHeading)
|
|
||||||
? travelHeading
|
|
||||||
: fallbackHeadingFromId(cameraId),
|
|
||||||
headingConfidence: 'low',
|
|
||||||
pitchDeg: -18,
|
|
||||||
fovDeg: 44,
|
|
||||||
rangeM: 145,
|
|
||||||
mountHeightM: 8,
|
|
||||||
groundElevationM: HOUSTON_GROUND_ELEVATION_M,
|
|
||||||
feedType: 'image',
|
|
||||||
url: imageUrl,
|
|
||||||
snapshotUrl: imageUrl,
|
|
||||||
sourceKind: 'houston-transtar',
|
|
||||||
license:
|
|
||||||
'TranStar disclaimer — redistribution and external framing need TxDOT written permission',
|
|
||||||
code: cameraDisplayCode(name.toUpperCase()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch Houston TranStar cameras from the keyless `cctvSnapshots_json.js`
|
|
||||||
* catalog. Frames are stills on `www.houstontranstar.org`, registered from
|
|
||||||
* a validated numeric id rather than from the payload URL. The catalog host
|
|
||||||
* answers `Accept: application/json` with HTTP 406, so this request accepts
|
|
||||||
* any media type.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
|
||||||
*/
|
|
||||||
export async function loadHoustonSourcesFromOpenData() {
|
|
||||||
try {
|
|
||||||
const endpoint =
|
|
||||||
process.env.CCTV_HOUSTON_CAMERAS_URL || HOUSTON_CAMERAS_URL;
|
|
||||||
const resp = await fetch(endpoint, {
|
|
||||||
headers: { Accept: '*/*' },
|
|
||||||
redirect: 'manual',
|
|
||||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
const discard = async () => {
|
|
||||||
try {
|
|
||||||
await resp.body?.cancel();
|
|
||||||
} catch {
|
|
||||||
/* no-op */
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
};
|
|
||||||
if (resp.status >= 300 && resp.status < 400) {
|
|
||||||
console.warn(
|
|
||||||
'[CCTV] Houston catalog redirected; redirects are not followed',
|
|
||||||
);
|
|
||||||
return discard();
|
|
||||||
}
|
|
||||||
if (!resp.ok) {
|
|
||||||
console.warn('[CCTV] Houston camera download failed:', resp.status);
|
|
||||||
return discard();
|
|
||||||
}
|
|
||||||
const payload = await readResponseJsonCapped(
|
|
||||||
resp,
|
|
||||||
HOUSTON_MAX_CATALOG_BYTES,
|
|
||||||
);
|
|
||||||
const rows = Array.isArray(payload?.cameras) ? payload.cameras : [];
|
|
||||||
const cameras = [];
|
|
||||||
const seen = new Set();
|
|
||||||
for (const record of rows) {
|
|
||||||
const camera = houstonCameraToSource(record);
|
|
||||||
if (!camera || seen.has(camera.id)) continue;
|
|
||||||
seen.add(camera.id);
|
|
||||||
cameras.push(camera);
|
|
||||||
}
|
|
||||||
const maxRaw = Number(
|
|
||||||
process.env.CCTV_HOUSTON_MAX_SOURCES || DEFAULT_HOUSTON_MAX_SOURCES,
|
|
||||||
);
|
|
||||||
const maxCount = Number.isFinite(maxRaw)
|
|
||||||
? Math.max(8, Math.min(400, Math.floor(maxRaw)))
|
|
||||||
: DEFAULT_HOUSTON_MAX_SOURCES;
|
|
||||||
const prioritized = prioritizeSources(cameras, maxCount, HOUSTON_ANCHORS);
|
|
||||||
console.log(
|
|
||||||
`[CCTV] Loaded Houston camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
|
|
||||||
);
|
|
||||||
return prioritized;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
'[CCTV] Houston camera download error:',
|
|
||||||
error?.message || error,
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,9 +5,7 @@ export const HLS_LIMITS = Object.freeze({
|
|||||||
sessions: 2,
|
sessions: 2,
|
||||||
leasesPerSession: 8,
|
leasesPerSession: 8,
|
||||||
playlistBytes: 256 * 1024,
|
playlistBytes: 256 * 1024,
|
||||||
// High-resolution cameras can exceed 4 MiB in a single ten-second TS segment.
|
segmentBytes: 4 * 1024 * 1024,
|
||||||
// Retained data still shares the independent 24 MiB session budget below.
|
|
||||||
segmentBytes: 8 * 1024 * 1024,
|
|
||||||
sessionBytes: 24 * 1024 * 1024,
|
sessionBytes: 24 * 1024 * 1024,
|
||||||
segments: 12,
|
segments: 12,
|
||||||
pollMs: 2000,
|
pollMs: 2000,
|
||||||
|
|||||||
+11
-158
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js';
|
||||||
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
|
||||||
TAIWAN_HIGHWAY_IMAGE_ORIGINS,
|
|
||||||
} from './constants.js';
|
|
||||||
import {
|
import {
|
||||||
cameraDisplayCode,
|
cameraDisplayCode,
|
||||||
fallbackHeadingFromId,
|
fallbackHeadingFromId,
|
||||||
@@ -13,8 +10,6 @@ import { readResponseTextCapped } from '../common/http.js';
|
|||||||
* standard, published keyless and refreshed daily (~1,800 cameras). */
|
* standard, published keyless and refreshed daily (~1,800 cameras). */
|
||||||
export const TAIWAN_FREEWAY_CCTV_URL =
|
export const TAIWAN_FREEWAY_CCTV_URL =
|
||||||
'https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml';
|
'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. */
|
/** The whole network is ~1.2 MB. */
|
||||||
export const TAIWAN_MAX_CATALOG_BYTES = 6 * 1024 * 1024;
|
export const TAIWAN_MAX_CATALOG_BYTES = 6 * 1024 * 1024;
|
||||||
const DEFAULT_TAIWAN_MAX_SOURCES = 600;
|
const DEFAULT_TAIWAN_MAX_SOURCES = 600;
|
||||||
@@ -40,9 +35,6 @@ const STREAM_TO_FRAME = new Map([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const DIRECTIONS = { N: 'N', S: 'S', E: 'E', W: 'W' };
|
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
|
/** Prioritization anchors, north to south, so a cap keeps the whole island
|
||||||
* rather than only greater Taipei. */
|
* rather than only greater Taipei. */
|
||||||
@@ -219,74 +211,6 @@ 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 lastGood = null;
|
||||||
let lastGoodAt = 0;
|
let lastGoodAt = 0;
|
||||||
let retryAfter = 0;
|
let retryAfter = 0;
|
||||||
@@ -313,49 +237,6 @@ function taiwanSourceCap() {
|
|||||||
: DEFAULT_TAIWAN_MAX_SOURCES;
|
: 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.
|
* 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
|
* One bounded request per shared catalog refresh. A failed download serves the
|
||||||
@@ -381,7 +262,7 @@ export async function loadTaiwanSources() {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
response.status >= 300 && response.status < 400
|
response.status >= 300 && response.status < 400
|
||||||
? 'catalog redirected; redirects are not followed'
|
? 'catalog redirected; redirects are not followed'
|
||||||
: 'HTTP ' + response.status,
|
: `HTTP ${response.status}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const xml = await readResponseTextCapped(
|
const xml = await readResponseTextCapped(
|
||||||
@@ -401,11 +282,7 @@ export async function loadTaiwanSources() {
|
|||||||
ANCHORS,
|
ANCHORS,
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
'[CCTV] Loaded Taiwan camera sources: ' +
|
`[CCTV] Loaded Taiwan camera sources: ${cameras.size} (using ${sources.length})`,
|
||||||
cameras.size +
|
|
||||||
' (using ' +
|
|
||||||
sources.length +
|
|
||||||
')',
|
|
||||||
);
|
);
|
||||||
lastGood = sources;
|
lastGood = sources;
|
||||||
lastGoodAt = Date.now();
|
lastGoodAt = Date.now();
|
||||||
@@ -414,37 +291,13 @@ export async function loadTaiwanSources() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
retryAfter = Date.now() + TAIWAN_RETRY_AFTER_FAILURE_MS;
|
retryAfter = Date.now() + TAIWAN_RETRY_AFTER_FAILURE_MS;
|
||||||
const fallback = fallbackSources();
|
const fallback = fallbackSources();
|
||||||
if (fallback.length) {
|
console.warn(
|
||||||
console.warn(
|
`[CCTV] Taiwan camera download failed (${error?.message || error}); ` +
|
||||||
'[CCTV] Taiwan Freeway camera download failed (' +
|
(fallback.length
|
||||||
(error?.message || error) +
|
? `serving the last good ${fallback.length} cameras`
|
||||||
'); serving the last good ' +
|
: 'no Taiwan cameras') +
|
||||||
fallback.length +
|
', next attempt in 1 h',
|
||||||
' cameras, next attempt in 1 h',
|
);
|
||||||
);
|
return fallback;
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { promises as fsp } from 'node:fs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A bounded two-level cache for JSON answers: memory (entry count) and disk
|
|
||||||
* (entry count, total bytes, age). Every memory insertion goes through one
|
|
||||||
* function, so a disk hit is bounded like a fresh answer; disk writes are
|
|
||||||
* followed by a prune that removes expired files first, then the oldest,
|
|
||||||
* until both quotas hold.
|
|
||||||
*
|
|
||||||
* @param {object} options
|
|
||||||
* @param {string|null} options.dir Disk directory; null keeps memory only.
|
|
||||||
* @param {number} options.ttlMs Age past which an entry is not served.
|
|
||||||
* @param {number} [options.maxMemoryEntries]
|
|
||||||
* @param {number} [options.maxDiskEntries]
|
|
||||||
* @param {number} [options.maxDiskBytes]
|
|
||||||
* @param {() => number} [options.now]
|
|
||||||
*/
|
|
||||||
export function createBoundedCache({
|
|
||||||
dir,
|
|
||||||
ttlMs,
|
|
||||||
maxMemoryEntries = 60,
|
|
||||||
maxDiskEntries = 400,
|
|
||||||
maxDiskBytes = 256 * 1024 * 1024,
|
|
||||||
now = () => Date.now(),
|
|
||||||
}) {
|
|
||||||
const memory = new Map();
|
|
||||||
let pruning = null;
|
|
||||||
let prunePending = false;
|
|
||||||
|
|
||||||
const fileFor = (key) =>
|
|
||||||
path.join(dir, `${createHash('sha1').update(key).digest('hex')}.json`);
|
|
||||||
|
|
||||||
function remember(key, entry) {
|
|
||||||
memory.delete(key);
|
|
||||||
memory.set(key, entry);
|
|
||||||
while (memory.size > maxMemoryEntries)
|
|
||||||
memory.delete(memory.keys().next().value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prune() {
|
|
||||||
if (!dir) return;
|
|
||||||
let names;
|
|
||||||
try {
|
|
||||||
names = (await fsp.readdir(dir)).filter((name) => name.endsWith('.json'));
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const files = [];
|
|
||||||
for (const name of names) {
|
|
||||||
const file = path.join(dir, name);
|
|
||||||
try {
|
|
||||||
const stat = await fsp.stat(file);
|
|
||||||
files.push({ file, size: stat.size, mtime: stat.mtimeMs });
|
|
||||||
} catch {
|
|
||||||
/* removed meanwhile */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const cutoff = now() - ttlMs;
|
|
||||||
const keep = [];
|
|
||||||
for (const entry of files) {
|
|
||||||
if (entry.mtime < cutoff) await fsp.rm(entry.file, { force: true });
|
|
||||||
else keep.push(entry);
|
|
||||||
}
|
|
||||||
keep.sort((a, b) => a.mtime - b.mtime);
|
|
||||||
let bytes = keep.reduce((sum, entry) => sum + entry.size, 0);
|
|
||||||
while (
|
|
||||||
keep.length &&
|
|
||||||
(keep.length > maxDiskEntries || bytes > maxDiskBytes)
|
|
||||||
) {
|
|
||||||
const oldest = keep.shift();
|
|
||||||
bytes -= oldest.size;
|
|
||||||
await fsp.rm(oldest.file, { force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run one prune at a time; a request during a prune schedules one more. */
|
|
||||||
function schedulePrune() {
|
|
||||||
if (pruning) {
|
|
||||||
prunePending = true;
|
|
||||||
return pruning;
|
|
||||||
}
|
|
||||||
pruning = prune()
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => {
|
|
||||||
pruning = null;
|
|
||||||
if (prunePending) {
|
|
||||||
prunePending = false;
|
|
||||||
schedulePrune();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return pruning;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
/** The cached value, or undefined. A disk hit is re-inserted into memory. */
|
|
||||||
async get(key) {
|
|
||||||
const held = memory.get(key);
|
|
||||||
if (held && now() - held.cachedAt <= ttlMs) return held.value;
|
|
||||||
if (held) memory.delete(key);
|
|
||||||
if (!dir) return undefined;
|
|
||||||
try {
|
|
||||||
const entry = JSON.parse(await fsp.readFile(fileFor(key), 'utf8'));
|
|
||||||
if (!Number.isFinite(entry?.cachedAt) || now() - entry.cachedAt > ttlMs)
|
|
||||||
return undefined;
|
|
||||||
remember(key, entry);
|
|
||||||
return entry.value;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
/** Store a value in memory and, when a directory is set, on disk. */
|
|
||||||
async set(key, value) {
|
|
||||||
const entry = { value, cachedAt: now() };
|
|
||||||
remember(key, entry);
|
|
||||||
if (!dir) return;
|
|
||||||
try {
|
|
||||||
await fsp.mkdir(dir, { recursive: true });
|
|
||||||
await fsp.writeFile(fileFor(key), JSON.stringify(entry));
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// In the background: an answer never waits on a 400-file directory scan.
|
|
||||||
// `prune()` returns the pending pass for callers that need it done.
|
|
||||||
schedulePrune();
|
|
||||||
},
|
|
||||||
/** Run a prune and wait until no pass is running or queued. */
|
|
||||||
async prune() {
|
|
||||||
schedulePrune();
|
|
||||||
while (pruning) await pruning;
|
|
||||||
},
|
|
||||||
get memorySize() {
|
|
||||||
return memory.size;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { isIPv6 } from 'node:net';
|
|
||||||
import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
||||||
export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
||||||
|
|
||||||
@@ -61,46 +60,3 @@ export function makeCostRateLimiter(envValue, defaultPerMin) {
|
|||||||
export function clientKey(req) {
|
export function clientKey(req) {
|
||||||
return String(req.socket?.remoteAddress || 'local');
|
return String(req.socket?.remoteAddress || 'local');
|
||||||
}
|
}
|
||||||
|
|
||||||
const IPV4_MAPPED_RE = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a socket peer address is this machine: IPv4 loopback in any of its
|
|
||||||
* forms (`127.0.0.0/8`, including the IPv4-mapped `::ffff:127.x` a dual-stack
|
|
||||||
* listener reports) or the IPv6 loopback. Non-address keys are not loopback.
|
|
||||||
* @param {string} address - A `clientKey` value.
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export function isLoopbackPeer(address) {
|
|
||||||
const raw = String(address || '');
|
|
||||||
const v4 = IPV4_MAPPED_RE.exec(raw)?.[1] ?? raw;
|
|
||||||
return v4.startsWith('127.') || raw === '::1';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate-limit key that survives address rotation, for limiters whose global
|
|
||||||
* backstop everyone shares. IPv6 privacy extensions hand one host a fresh
|
|
||||||
* address at will inside its /64, so keying by the full address lets a single
|
|
||||||
* host mint per-peer quota until the global window closes for everyone else.
|
|
||||||
* IPv6 peers are keyed by their /64 prefix instead, and an IPv4-mapped
|
|
||||||
* address folds to its IPv4 form so one host cannot hold two buckets. Any
|
|
||||||
* other value (IPv4, or the `local` fallback) passes through unchanged.
|
|
||||||
* @param {import('node:http').IncomingMessage} req
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function clientNetworkKey(req) {
|
|
||||||
const address = clientKey(req);
|
|
||||||
const mapped = IPV4_MAPPED_RE.exec(address);
|
|
||||||
if (mapped) return mapped[1];
|
|
||||||
const bare = address.replace(/%.*$/, ''); // link-local zone id
|
|
||||||
if (!isIPv6(bare)) return address;
|
|
||||||
const [head, tail = ''] = bare.split('::');
|
|
||||||
const headParts = head ? head.split(':') : [];
|
|
||||||
const tailParts = tail ? tail.split(':') : [];
|
|
||||||
const missing = Math.max(0, 8 - headParts.length - tailParts.length);
|
|
||||||
const hextets = [...headParts, ...Array(missing).fill('0'), ...tailParts];
|
|
||||||
return `${hextets
|
|
||||||
.slice(0, 4)
|
|
||||||
.map((h) => h.toLowerCase().replace(/^0+(?=.)/, ''))
|
|
||||||
.join(':')}::/64`;
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { clientNetworkKey, isLoopbackPeer } from './rate-limit.js';
|
|
||||||
|
|
||||||
const req = (remoteAddress) => ({ socket: { remoteAddress } });
|
|
||||||
|
|
||||||
test('clientNetworkKey keys IPv6 peers by /64 and folds IPv4-mapped peers', () => {
|
|
||||||
assert.equal(clientNetworkKey(req('2001:db8:1:2::1')), '2001:db8:1:2::/64');
|
|
||||||
assert.equal(
|
|
||||||
clientNetworkKey(req('2001:0db8:0001:0002:abcd:ef01:2345:6789')),
|
|
||||||
'2001:db8:1:2::/64',
|
|
||||||
);
|
|
||||||
assert.equal(clientNetworkKey(req('2001:DB8::1')), '2001:db8:0:0::/64');
|
|
||||||
assert.equal(clientNetworkKey(req('fe80::1%en0')), 'fe80:0:0:0::/64');
|
|
||||||
assert.equal(clientNetworkKey(req('::1')), '0:0:0:0::/64');
|
|
||||||
assert.equal(clientNetworkKey(req('::ffff:10.0.0.5')), '10.0.0.5');
|
|
||||||
assert.equal(clientNetworkKey(req('10.0.0.5')), '10.0.0.5');
|
|
||||||
assert.equal(clientNetworkKey(req(undefined)), 'local');
|
|
||||||
assert.equal(clientNetworkKey(req('not-an-address')), 'not-an-address');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('isLoopbackPeer recognizes every loopback form and nothing else', () => {
|
|
||||||
for (const address of ['127.0.0.1', '127.9.9.9', '::1', '::ffff:127.0.0.1']) {
|
|
||||||
assert.equal(isLoopbackPeer(address), true, address);
|
|
||||||
}
|
|
||||||
for (const address of [
|
|
||||||
'10.0.0.1',
|
|
||||||
'::ffff:10.0.0.1',
|
|
||||||
'2001:db8::1',
|
|
||||||
'::2',
|
|
||||||
'local',
|
|
||||||
'',
|
|
||||||
undefined,
|
|
||||||
]) {
|
|
||||||
assert.equal(isLoopbackPeer(address), false, String(address));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -58,7 +58,7 @@ function forwardedProtocol(req) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Cross-site gate for keyless routes the map needs in order to render
|
* Cross-site gate for keyless routes the map needs in order to render
|
||||||
* (`/api/terrain/heights`, `/api/overpass`). It refuses the same cross-site browser shapes as
|
* (`/api/terrain/heights`). It refuses the same cross-site browser shapes as
|
||||||
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
|
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
|
||||||
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
|
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
|
||||||
* those protect credentials and spend, which a keyless route has neither of,
|
* those protect credentials and spend, which a keyless route has neither of,
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
import { readRequestBody } from './request.js';
|
|
||||||
import {
|
|
||||||
clientKey,
|
|
||||||
makeCostRateLimiter,
|
|
||||||
resolvePerMinuteCap,
|
|
||||||
} from './rate-limit.js';
|
|
||||||
import { waitForSignal } from '../../../src/sources/requestDeadline.js';
|
|
||||||
import { readResponseJsonCapped } from '../../../src/sources/httpBody.js';
|
|
||||||
import { isGeminiRequestOriginAllowed } from '../gemini/request.js';
|
|
||||||
import { parseUsage } from './textTools.js';
|
|
||||||
|
|
||||||
const MAX_BYTES = 512 * 1024;
|
|
||||||
const FAILURE = 'Could not connect. Try again.';
|
|
||||||
// A rejected key is not retried upstream for this long, unless the key changes.
|
|
||||||
const REJECTED_KEY_COOLDOWN_MS = 60_000;
|
|
||||||
|
|
||||||
// What an upstream failure says about the key or the account. Gemini answers
|
|
||||||
// an invalid key with 400 and an ErrorInfo reason, not 401; OpenAI answers an
|
|
||||||
// account without credit with 429 insufficient_quota, which waiting won't fix.
|
|
||||||
// A 403 is the key's project, region or permissions, so it cools down too but
|
|
||||||
// does not blame the key. Only small error bodies are read.
|
|
||||||
async function upstreamRefusal(response, signal) {
|
|
||||||
const { status } = response;
|
|
||||||
if (status === 401) return 'API_KEY_REJECTED';
|
|
||||||
if (status === 403) return 'ACCESS_DENIED';
|
|
||||||
if (![400, 429].includes(status)) return null;
|
|
||||||
let error;
|
|
||||||
try {
|
|
||||||
error = (await readResponseJsonCapped(response, 16 * 1024, signal))?.error;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
status === 400 &&
|
|
||||||
Array.isArray(error?.details) &&
|
|
||||||
error.details.some((detail) => detail?.reason === 'API_KEY_INVALID')
|
|
||||||
)
|
|
||||||
return 'API_KEY_REJECTED';
|
|
||||||
if (status === 429 && error?.code === 'insufficient_quota')
|
|
||||||
return 'QUOTA_EXCEEDED';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass through only a delay in whole seconds, capped at an hour.
|
|
||||||
function upstreamRetryAfter(response) {
|
|
||||||
const value = String(response.headers?.get?.('retry-after') ?? '').trim();
|
|
||||||
if (!/^\d{1,6}$/.test(value)) return null;
|
|
||||||
return String(Math.min(Math.max(Number(value), 1), 3600));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTextChatHandler({
|
|
||||||
provider,
|
|
||||||
prepare,
|
|
||||||
parse,
|
|
||||||
fetchImpl = (...args) => fetch(...args),
|
|
||||||
resolveApiKey,
|
|
||||||
resolveHost = () => process.env.HOST,
|
|
||||||
resolveAllowedHosts = () => process.env.GEV_ALLOWED_HOSTS,
|
|
||||||
resolveRateLimit = () =>
|
|
||||||
process.env[`GEV_RATELIMIT_${provider.toUpperCase()}_CHAT_PER_MIN`],
|
|
||||||
// An unset chat throttle follows the provider's own cost throttle.
|
|
||||||
resolveProviderRateLimit = () => undefined,
|
|
||||||
providerPerMin = 30,
|
|
||||||
// Returns { error, code } when the route must refuse before any key is used.
|
|
||||||
refuse = () => null,
|
|
||||||
now = Date.now,
|
|
||||||
timeoutMs = 60000,
|
|
||||||
} = {}) {
|
|
||||||
let limiter;
|
|
||||||
let rejected = null;
|
|
||||||
return async (req, res) => {
|
|
||||||
if (req.aborted || res.destroyed || res.writableEnded) return;
|
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
|
||||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
||||||
const reply = (status, value, code) => {
|
|
||||||
if (req.aborted || res.destroyed || res.writableEnded) return;
|
|
||||||
res.statusCode = status;
|
|
||||||
if (status >= 400) res.setHeader('Connection', 'close');
|
|
||||||
res.end(
|
|
||||||
JSON.stringify(
|
|
||||||
typeof value === 'string'
|
|
||||||
? { error: value, ...(code ? { code } : {}) }
|
|
||||||
: value,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
if (req.method !== 'POST') {
|
|
||||||
res.setHeader('Allow', 'POST');
|
|
||||||
return reply(405, 'Method not allowed');
|
|
||||||
}
|
|
||||||
const lifetime = new AbortController();
|
|
||||||
const disconnect = () => {
|
|
||||||
if (!res.writableEnded) lifetime.abort();
|
|
||||||
};
|
|
||||||
req.once('aborted', disconnect);
|
|
||||||
res.once('close', disconnect);
|
|
||||||
const timer = setTimeout(() => lifetime.abort(), timeoutMs);
|
|
||||||
timer.unref?.();
|
|
||||||
let response;
|
|
||||||
let usage = null;
|
|
||||||
try {
|
|
||||||
if (
|
|
||||||
!isGeminiRequestOriginAllowed(req, resolveHost(), resolveAllowedHosts())
|
|
||||||
)
|
|
||||||
return reply(403, FAILURE);
|
|
||||||
const refusal = refuse();
|
|
||||||
if (refusal) return reply(503, refusal.error, refusal.code);
|
|
||||||
const apiKey = String(resolveApiKey() || '').trim();
|
|
||||||
if (!apiKey)
|
|
||||||
return reply(
|
|
||||||
503,
|
|
||||||
'Add an API key in Provider Settings',
|
|
||||||
'API_KEY_MISSING',
|
|
||||||
);
|
|
||||||
if (rejected && (rejected.key !== apiKey || now() >= rejected.until))
|
|
||||||
rejected = null;
|
|
||||||
if (rejected) {
|
|
||||||
res.setHeader(
|
|
||||||
'Retry-After',
|
|
||||||
String(Math.max(1, Math.ceil((rejected.until - now()) / 1000))),
|
|
||||||
);
|
|
||||||
return reply(502, FAILURE, rejected.code);
|
|
||||||
}
|
|
||||||
if (limiter === undefined)
|
|
||||||
limiter = makeCostRateLimiter(
|
|
||||||
resolveRateLimit(),
|
|
||||||
resolvePerMinuteCap(resolveProviderRateLimit(), providerPerMin),
|
|
||||||
);
|
|
||||||
if (limiter && !limiter(clientKey(req))) {
|
|
||||||
res.setHeader('Retry-After', '60');
|
|
||||||
return reply(429, FAILURE, 'RATE_LIMITED');
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] || '')
|
|
||||||
)
|
|
||||||
return reply(415, FAILURE);
|
|
||||||
if (Number(req.headers['content-length']) > MAX_BYTES)
|
|
||||||
return reply(413, FAILURE);
|
|
||||||
const raw = await readRequestBody(req, MAX_BYTES, lifetime.signal);
|
|
||||||
let input;
|
|
||||||
try {
|
|
||||||
input = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return reply(400, FAILURE);
|
|
||||||
}
|
|
||||||
const prepared = prepare(input);
|
|
||||||
if (!prepared) return reply(400, FAILURE);
|
|
||||||
if (prepared.errorStatus) return reply(prepared.errorStatus, FAILURE);
|
|
||||||
const { endpoint, body, headers } = prepared;
|
|
||||||
lifetime.signal.throwIfAborted();
|
|
||||||
const acquisition = Promise.resolve(
|
|
||||||
fetchImpl(endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
redirect: 'error',
|
|
||||||
signal: lifetime.signal,
|
|
||||||
headers: { 'Content-Type': 'application/json', ...headers(apiKey) },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
void acquisition.then(
|
|
||||||
(late) => {
|
|
||||||
response = late;
|
|
||||||
if (lifetime.signal.aborted) void late.body?.cancel().catch(() => {});
|
|
||||||
},
|
|
||||||
() => {},
|
|
||||||
);
|
|
||||||
response = await waitForSignal(acquisition, lifetime.signal);
|
|
||||||
lifetime.signal.throwIfAborted();
|
|
||||||
if (response.redirected || (response.url && response.url !== endpoint))
|
|
||||||
throw new Error('Unexpected destination');
|
|
||||||
if (!response.ok) {
|
|
||||||
const refusal = await upstreamRefusal(response, lifetime.signal);
|
|
||||||
if (refusal) {
|
|
||||||
void response.body?.cancel().catch(() => {});
|
|
||||||
rejected = {
|
|
||||||
key: apiKey,
|
|
||||||
code: refusal,
|
|
||||||
until: now() + REJECTED_KEY_COOLDOWN_MS,
|
|
||||||
};
|
|
||||||
return reply(502, FAILURE, refusal);
|
|
||||||
}
|
|
||||||
void response.body?.cancel().catch(() => {});
|
|
||||||
if (![429, 503].includes(response.status)) return reply(502, FAILURE);
|
|
||||||
const retryAfter = upstreamRetryAfter(response);
|
|
||||||
if (retryAfter) res.setHeader('Retry-After', retryAfter);
|
|
||||||
return reply(
|
|
||||||
response.status,
|
|
||||||
FAILURE,
|
|
||||||
response.status === 429 ? 'RATE_LIMITED' : undefined,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const data = await readResponseJsonCapped(
|
|
||||||
response,
|
|
||||||
MAX_BYTES,
|
|
||||||
lifetime.signal,
|
|
||||||
);
|
|
||||||
lifetime.signal.throwIfAborted();
|
|
||||||
usage = parseUsage(provider, data);
|
|
||||||
const result = parse(data);
|
|
||||||
if (!result || JSON.stringify(result).includes(apiKey))
|
|
||||||
return reply(502, { error: FAILURE, usage });
|
|
||||||
reply(200, { ...result, usage });
|
|
||||||
} catch (error) {
|
|
||||||
reply(
|
|
||||||
error?.code === 'BODY_TOO_LARGE'
|
|
||||||
? 413
|
|
||||||
: lifetime.signal.aborted
|
|
||||||
? 504
|
|
||||||
: 502,
|
|
||||||
{ error: FAILURE, usage },
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (lifetime.signal.aborted)
|
|
||||||
void response?.body?.cancel().catch(() => {});
|
|
||||||
clearTimeout(timer);
|
|
||||||
req.off('aborted', disconnect);
|
|
||||||
res.off('close', disconnect);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { realtimeDirectives } from '../openai/instructions.js';
|
|
||||||
|
|
||||||
// Voice wording in the shared directives and its written form. A test checks
|
|
||||||
// each phrase still occurs in the directives, so rewording one there fails
|
|
||||||
// rather than leaking spoken wording into chat.
|
|
||||||
export const TEXT_WORDING = Object.freeze([
|
|
||||||
['without speaking first', 'without writing a preamble'],
|
|
||||||
['before speaking', 'before confirming'],
|
|
||||||
['may first say ONE sentence', 'may first write ONE sentence'],
|
|
||||||
["speak the result's say line", "write the result's say line"],
|
|
||||||
['on the voice card: speak a caveat', 'in the tool result: include a caveat'],
|
|
||||||
['as you talk', 'as you write'],
|
|
||||||
['you can read aloud', 'you can report'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Shared-manual adaptation follows Darren Dignam's GEV COMMAND approach (#976).
|
|
||||||
// Keep our Radio handoff and annotation outcome policies from the shared manual.
|
|
||||||
// `options.overpass` matches the tools offered, as for voice.
|
|
||||||
export function textInstructions(annotationGuidance, options) {
|
|
||||||
return realtimeDirectives(annotationGuidance, options)
|
|
||||||
.map((line, index) => {
|
|
||||||
if (index === 0)
|
|
||||||
return "You are GEV Chat, a concise text assistant for a Cesium geospatial app called God's Eye View.";
|
|
||||||
if (index === 1)
|
|
||||||
return 'Have a natural written conversation. This is text-only chat; no microphone or generated audio is used.';
|
|
||||||
return TEXT_WORDING.reduce(
|
|
||||||
(text, [spoken, written]) => text.replace(spoken, written),
|
|
||||||
line,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { validateValue } from '../../../src/tools/schema.js';
|
|
||||||
|
|
||||||
export function invalidCalls(calls, tools) {
|
|
||||||
const schemas = new Map(tools.map((tool) => [tool.name, tool.parameters]));
|
|
||||||
return calls.flatMap((call, index) => {
|
|
||||||
const schema = schemas.get(call.name);
|
|
||||||
const errors = !schema
|
|
||||||
? ['Unknown tool']
|
|
||||||
: call.parseError
|
|
||||||
? ['Arguments must be valid JSON']
|
|
||||||
: validateValue(schema, call.args === undefined ? {} : call.args);
|
|
||||||
return errors.length
|
|
||||||
? [{ index, error: errors.slice(0, 4).join('; ').slice(0, 1000) }]
|
|
||||||
: [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Missing usage stays unknown, never an invented zero-cost request.
|
|
||||||
export function parseUsage(provider, data) {
|
|
||||||
const raw = provider === 'gemini' ? data?.usageMetadata : data?.usage;
|
|
||||||
const number = (value) =>
|
|
||||||
Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
||||||
const inputTokens = number(
|
|
||||||
provider === 'gemini' ? raw?.promptTokenCount : raw?.prompt_tokens,
|
|
||||||
);
|
|
||||||
const candidateTokens = number(
|
|
||||||
provider === 'gemini' ? raw?.candidatesTokenCount : raw?.completion_tokens,
|
|
||||||
);
|
|
||||||
const totalTokens = number(
|
|
||||||
provider === 'gemini' ? raw?.totalTokenCount : raw?.total_tokens,
|
|
||||||
);
|
|
||||||
if (inputTokens === null || candidateTokens === null || totalTokens === null)
|
|
||||||
return null;
|
|
||||||
const reasoningTokens =
|
|
||||||
number(
|
|
||||||
provider === 'gemini'
|
|
||||||
? raw?.thoughtsTokenCount
|
|
||||||
: raw?.completion_tokens_details?.reasoning_tokens,
|
|
||||||
) ?? 0;
|
|
||||||
const outputTokens =
|
|
||||||
candidateTokens + (provider === 'gemini' ? reasoningTokens : 0);
|
|
||||||
return {
|
|
||||||
inputTokens,
|
|
||||||
outputTokens,
|
|
||||||
totalTokens,
|
|
||||||
reasoningTokens,
|
|
||||||
cachedInputTokens:
|
|
||||||
number(
|
|
||||||
provider === 'gemini'
|
|
||||||
? raw?.cachedContentTokenCount
|
|
||||||
: raw?.prompt_tokens_details?.cached_tokens,
|
|
||||||
) ?? 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
/** 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)`;
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { normalizeDroneAirspaceSnapshot } from '../../src/layers/droneAirspace/records.js';
|
|
||||||
import { readResponseJsonCapped, coalesceProxyRequest } from './common/http.js';
|
|
||||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
|
||||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
|
||||||
|
|
||||||
// FAA Special Use Airspace (public, keyless). TYPE_CODE P (prohibited) and
|
|
||||||
// R (restricted) only — the small, always-in-force slice of the national
|
|
||||||
// SUA table. maxAllowableOffset trades ~100 m of boundary fidelity for a
|
|
||||||
// payload small enough to refresh (~330 KB for the current P+R set).
|
|
||||||
const API_URL =
|
|
||||||
'https://services6.arcgis.com/ssFJjBXIUyZDrSYZ/arcgis/rest/services/' +
|
|
||||||
'Special_Use_Airspace/FeatureServer/0/query?' +
|
|
||||||
new URLSearchParams({
|
|
||||||
where: "TYPE_CODE IN ('P','R')",
|
|
||||||
outFields: [
|
|
||||||
'NAME',
|
|
||||||
'TYPE_CODE',
|
|
||||||
'UPPER_VAL',
|
|
||||||
'UPPER_UOM',
|
|
||||||
'UPPER_CODE',
|
|
||||||
'LOWER_VAL',
|
|
||||||
'LOWER_UOM',
|
|
||||||
'LOWER_CODE',
|
|
||||||
'TIMESOFUSE',
|
|
||||||
'CONT_AGENT',
|
|
||||||
'CITY',
|
|
||||||
'STATE',
|
|
||||||
'OBJECTID',
|
|
||||||
].join(','),
|
|
||||||
maxAllowableOffset: '0.001',
|
|
||||||
outSR: '4326',
|
|
||||||
f: 'geojson',
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
const MAX_PAGES = 5;
|
|
||||||
const MIB = 1024 * 1024;
|
|
||||||
const SNAPSHOT_TTL_MS = 3_600_000;
|
|
||||||
/** Longest a failed refresh keeps serving the last snapshot, marked stale. */
|
|
||||||
const STALE_MAX_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const exceededTransferLimit = (payload) =>
|
|
||||||
payload?.exceededTransferLimit === true ||
|
|
||||||
payload?.properties?.exceededTransferLimit === true;
|
|
||||||
|
|
||||||
/** Fixed-origin, bounded FAA SUA routes for dev and preview. */
|
|
||||||
export function droneAirspaceProxy({
|
|
||||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
|
||||||
now = () => Date.now(),
|
|
||||||
} = {}) {
|
|
||||||
const cache = new Map();
|
|
||||||
const inFlight = new Map();
|
|
||||||
const allow = makeRateLimiter({ windowMs: 60_000, max: 60, globalMax: 1200 });
|
|
||||||
|
|
||||||
async function upstream(url, cap, timeout) {
|
|
||||||
const signal = AbortSignal.timeout(timeout);
|
|
||||||
const response = await fetchImpl(url, {
|
|
||||||
signal,
|
|
||||||
redirect: 'error',
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
await response.body?.cancel();
|
|
||||||
throw new Error('upstream_unavailable');
|
|
||||||
}
|
|
||||||
return readResponseJsonCapped(response, cap, signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A snapshot is accepted only once the service says it is complete. A page
|
|
||||||
// limit reached while still flagged, a flagged page that adds nothing, or
|
|
||||||
// no valid zone at all is a failed refresh, so the last good snapshot stays.
|
|
||||||
async function fetchZones() {
|
|
||||||
const features = [];
|
|
||||||
let complete = false;
|
|
||||||
for (let page = 0; page < MAX_PAGES; page++) {
|
|
||||||
const url =
|
|
||||||
page === 0 ? API_URL : `${API_URL}&resultOffset=${features.length}`;
|
|
||||||
const payload = await upstream(url, 8 * MIB, 30_000);
|
|
||||||
if (!Array.isArray(payload?.features))
|
|
||||||
throw new Error('invalid_snapshot');
|
|
||||||
features.push(...payload.features);
|
|
||||||
if (!exceededTransferLimit(payload)) {
|
|
||||||
complete = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!payload.features.length) break;
|
|
||||||
}
|
|
||||||
if (!complete) throw new Error('incomplete_snapshot');
|
|
||||||
const rows = normalizeDroneAirspaceSnapshot({ features });
|
|
||||||
if (!rows?.length) throw new Error('invalid_snapshot');
|
|
||||||
return { fetchedAt: now(), rows };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function acquire(key, ttl, load) {
|
|
||||||
const previous = cache.get(key);
|
|
||||||
if (previous && now() - previous.savedAt < ttl)
|
|
||||||
return { value: previous.value, stale: false };
|
|
||||||
if (!inFlight.has(key) && inFlight.size >= 256)
|
|
||||||
throw Object.assign(new Error('busy'), { status: 429 });
|
|
||||||
try {
|
|
||||||
const { promise } = coalesceProxyRequest(inFlight, key, async () => {
|
|
||||||
const value = await load();
|
|
||||||
cache.set(key, { value, savedAt: now() });
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
return { value: await promise, stale: false };
|
|
||||||
} catch (error) {
|
|
||||||
// A failed refresh serves the last snapshot, marked stale, for a day.
|
|
||||||
if (previous && now() - previous.savedAt <= STALE_MAX_MS)
|
|
||||||
return { value: previous.value, stale: true };
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handler(req, res) {
|
|
||||||
const json = (status, value, stale = false) => {
|
|
||||||
if (res.destroyed) return;
|
|
||||||
res.writeHead(status, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
...(status === 405 ? { Allow: 'GET' } : {}),
|
|
||||||
...(status === 429 ? { 'Retry-After': '60' } : {}),
|
|
||||||
...(stale ? { 'X-Data-Stale': 'true' } : {}),
|
|
||||||
});
|
|
||||||
res.end(JSON.stringify(value));
|
|
||||||
};
|
|
||||||
// Keyless and needed to render the layer: refuse cross-site browsers
|
|
||||||
// before they spend a client's quota or start a refresh, but keep the
|
|
||||||
// documented reverse-proxy deployment working.
|
|
||||||
if (admitSameSiteKeyless(req, res)) return;
|
|
||||||
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
|
|
||||||
const path = (req.url || '/').split('?')[0];
|
|
||||||
if (path !== '/' && path !== '')
|
|
||||||
return json(404, { error: 'unknown_route' });
|
|
||||||
if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' });
|
|
||||||
try {
|
|
||||||
const { value, stale } = await acquire(
|
|
||||||
'zones',
|
|
||||||
SNAPSHOT_TTL_MS,
|
|
||||||
fetchZones,
|
|
||||||
);
|
|
||||||
json(200, stale ? { ...value, stale: true } : value, stale);
|
|
||||||
} catch (error) {
|
|
||||||
json(error.status === 429 ? 429 : 502, {
|
|
||||||
error: 'drone_airspace_unavailable',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: 'drone-airspace',
|
|
||||||
configureServer({ middlewares }) {
|
|
||||||
middlewares.use('/api/drone-airspace', handler);
|
|
||||||
},
|
|
||||||
configurePreviewServer({ middlewares }) {
|
|
||||||
middlewares.use('/api/drone-airspace', handler);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
import { createGeminiChatHandler } from './gemini/chat.js';
|
|
||||||
import { createGeminiTokenHandler } from './gemini/realtime.js';
|
import { createGeminiTokenHandler } from './gemini/realtime.js';
|
||||||
|
|
||||||
/** Mint constrained Live API credentials; the permanent key stays on Node. */
|
/** Mint constrained Live API credentials; the permanent key stays on Node. */
|
||||||
function geminiLiveProxy({ annotationGuidance, realtime = {} } = {}) {
|
function geminiLiveProxy({ annotationGuidance, realtime = {} } = {}) {
|
||||||
function install(middlewares) {
|
function install(middlewares) {
|
||||||
middlewares.use(
|
|
||||||
'/api/gemini/chat',
|
|
||||||
createGeminiChatHandler({ ...realtime, annotationGuidance }),
|
|
||||||
);
|
|
||||||
middlewares.use(
|
middlewares.use(
|
||||||
'/api/gemini/token',
|
'/api/gemini/token',
|
||||||
createGeminiTokenHandler({ ...realtime, annotationGuidance }),
|
createGeminiTokenHandler({ ...realtime, annotationGuidance }),
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
import { createTextChatHandler } from '../common/textChat.js';
|
|
||||||
import { invalidCalls } from '../common/textTools.js';
|
|
||||||
import { textInstructions } from '../common/textInstructions.js';
|
|
||||||
import { GEV_REALTIME_TOOLS, offeredTools } from '../openai/tools.js';
|
|
||||||
import { resolveOverpassUpstreams } from '../overpass/constants.js';
|
|
||||||
import { normalizeGeminiModel } from './config.js';
|
|
||||||
|
|
||||||
// The Gemini token route's throttle default (GEV_RATELIMIT_GEMINI_PER_MIN).
|
|
||||||
export const GEMINI_DEFAULT_PER_MIN = 30;
|
|
||||||
|
|
||||||
const object = (value) =>
|
|
||||||
value && typeof value === 'object' && !Array.isArray(value);
|
|
||||||
const keys = (value, allowed) =>
|
|
||||||
Object.keys(value).every((key) => allowed.includes(key));
|
|
||||||
|
|
||||||
// Only text and function exchanges may cross this route, never media or config.
|
|
||||||
export function validChatContents(contents, names) {
|
|
||||||
return (
|
|
||||||
Array.isArray(contents) &&
|
|
||||||
contents.length > 0 &&
|
|
||||||
contents.length <= 96 &&
|
|
||||||
contents.every(
|
|
||||||
(content) =>
|
|
||||||
object(content) &&
|
|
||||||
keys(content, ['role', 'parts']) &&
|
|
||||||
['user', 'model'].includes(content.role) &&
|
|
||||||
Array.isArray(content.parts) &&
|
|
||||||
content.parts.length > 0 &&
|
|
||||||
content.parts.length <= 32 &&
|
|
||||||
content.parts.every((part) => {
|
|
||||||
if (
|
|
||||||
!object(part) ||
|
|
||||||
!keys(part, [
|
|
||||||
'text',
|
|
||||||
'functionCall',
|
|
||||||
'functionResponse',
|
|
||||||
'thoughtSignature',
|
|
||||||
])
|
|
||||||
)
|
|
||||||
return false;
|
|
||||||
if (
|
|
||||||
part.thoughtSignature !== undefined &&
|
|
||||||
(typeof part.thoughtSignature !== 'string' ||
|
|
||||||
part.thoughtSignature.length > 32000 ||
|
|
||||||
content.role !== 'model')
|
|
||||||
)
|
|
||||||
return false;
|
|
||||||
if (Object.hasOwn(part, 'text'))
|
|
||||||
return (
|
|
||||||
keys(part, ['text', 'thoughtSignature']) &&
|
|
||||||
typeof part.text === 'string'
|
|
||||||
);
|
|
||||||
const call = part.functionCall ?? part.functionResponse;
|
|
||||||
if (
|
|
||||||
!object(call) ||
|
|
||||||
typeof call.name !== 'string' ||
|
|
||||||
call.name.length > 128 ||
|
|
||||||
(names && !names.has(call.name)) ||
|
|
||||||
(call.id !== undefined &&
|
|
||||||
(typeof call.id !== 'string' || call.id.length > 1024))
|
|
||||||
)
|
|
||||||
return false;
|
|
||||||
if (part.functionCall)
|
|
||||||
return (
|
|
||||||
content.role === 'model' &&
|
|
||||||
keys(part, ['functionCall', 'thoughtSignature']) &&
|
|
||||||
keys(call, ['name', 'args', 'id']) &&
|
|
||||||
(call.args === null || object(call.args ?? {}))
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
content.role === 'user' &&
|
|
||||||
keys(part, ['functionResponse']) &&
|
|
||||||
keys(call, ['name', 'response', 'id']) &&
|
|
||||||
object(call.response)
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGeminiChatHandler({
|
|
||||||
annotationGuidance,
|
|
||||||
tools = GEV_REALTIME_TOOLS,
|
|
||||||
// osm_query is offered, and the prompt names it, only with Overpass, as
|
|
||||||
// for voice.
|
|
||||||
overpassConfigured = () => resolveOverpassUpstreams().length > 0,
|
|
||||||
resolveChatModel = () =>
|
|
||||||
process.env.GEMINI_CHAT_MODEL || 'gemini-3.5-flash-lite',
|
|
||||||
resolveApiKey = () => process.env.GEMINI_API_KEY,
|
|
||||||
resolveProviderRateLimit = () => process.env.GEV_RATELIMIT_GEMINI_PER_MIN,
|
|
||||||
...options
|
|
||||||
} = {}) {
|
|
||||||
return createTextChatHandler({
|
|
||||||
...options,
|
|
||||||
provider: 'gemini',
|
|
||||||
resolveApiKey,
|
|
||||||
resolveProviderRateLimit,
|
|
||||||
providerPerMin: GEMINI_DEFAULT_PER_MIN,
|
|
||||||
prepare(input) {
|
|
||||||
const model = normalizeGeminiModel(resolveChatModel());
|
|
||||||
if (!model || /live|audio|tts/i.test(model)) return { errorStatus: 503 };
|
|
||||||
if (
|
|
||||||
!object(input) ||
|
|
||||||
!keys(input, ['contents']) ||
|
|
||||||
!validChatContents(input.contents, null)
|
|
||||||
)
|
|
||||||
return null;
|
|
||||||
if (!pairedContents(input.contents)) return null;
|
|
||||||
const overpass = overpassConfigured();
|
|
||||||
return {
|
|
||||||
endpoint: `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
|
|
||||||
headers: (key) => ({ 'x-goog-api-key': key }),
|
|
||||||
body: {
|
|
||||||
contents: input.contents,
|
|
||||||
systemInstruction: {
|
|
||||||
parts: [
|
|
||||||
{ text: textInstructions(annotationGuidance, { overpass }) },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
functionDeclarations: offeredTools(tools, { overpass }).map(
|
|
||||||
({ name, description, parameters }) => ({
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
parametersJsonSchema: parameters,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generationConfig: {
|
|
||||||
responseModalities: ['TEXT'],
|
|
||||||
maxOutputTokens: 4096,
|
|
||||||
thinkingConfig: { thinkingLevel: 'LOW' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
parse(data) {
|
|
||||||
const candidate = data?.candidates?.[0];
|
|
||||||
const content = candidate?.content;
|
|
||||||
if (
|
|
||||||
!content ||
|
|
||||||
!validChatContents([content], null) ||
|
|
||||||
content.role !== 'model'
|
|
||||||
)
|
|
||||||
return null;
|
|
||||||
const calls = content.parts
|
|
||||||
.filter((part) => part.functionCall)
|
|
||||||
.map((part) => part.functionCall);
|
|
||||||
return {
|
|
||||||
content,
|
|
||||||
finishReason: candidate.finishReason,
|
|
||||||
invalidCalls: invalidCalls(
|
|
||||||
calls,
|
|
||||||
offeredTools(tools, { overpass: overpassConfigured() }),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Never forward an orphan result or an unfinished batch in browser history.
|
|
||||||
function pairedContents(contents) {
|
|
||||||
let pending = [];
|
|
||||||
for (const content of contents) {
|
|
||||||
const calls = content.parts
|
|
||||||
.filter((part) => part.functionCall)
|
|
||||||
.map((part) => part.functionCall);
|
|
||||||
const results = content.parts
|
|
||||||
.filter((part) => part.functionResponse)
|
|
||||||
.map((part) => part.functionResponse);
|
|
||||||
if (pending.length) {
|
|
||||||
if (
|
|
||||||
calls.length ||
|
|
||||||
results.length !== pending.length ||
|
|
||||||
results.some(
|
|
||||||
(result, i) =>
|
|
||||||
result.name !== pending[i].name || result.id !== pending[i].id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return false;
|
|
||||||
pending = [];
|
|
||||||
} else if (results.length) return false;
|
|
||||||
if (calls.length) pending = calls;
|
|
||||||
}
|
|
||||||
return !pending.length;
|
|
||||||
}
|
|
||||||
Loaded 100 of 629 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user