Compare commits

..
Author SHA1 Message Date
Said TurkseverandMaher Kurdi dae423f7e2 Add Amsterdam traffic and ANPR camera positions to the Data Library
The City of Amsterdam's verkeersinformatiesystemen register lists where
its ~500 traffic and ANPR cameras stand, with type and asset number, but
publishes no pictures. That fits a Data Library dataset better than a
CCTV pack: the register answers GeoJSON in WGS84 with CORS headers, so
the entry fetches it browser-direct with no proxy code.

The register names no licence ("public unless stated otherwise"), so the
entry stays operator opt-in, as #251 had it. The dataset and the
position-only camera idea come from @Maher-Reven's #251, which this
replaces; the entry lists them as maintainer and in its attribution.

Co-authored-by: Maher Kurdi <[email protected]>
2026-11-07 18:24:01 +00:00
Said Turksever a3f3d8c585 Merge branch 'main' into feat/data-library 2026-11-06 15:03:01 +00:00
Said Turksever f538813032 Add changelog fragment for the Data Library 2026-11-06 12:37:42 +00:00
Said Turksever 9c4bb873b8 Merge origin/main into feat/data-library
Main moved the data layer rows to Vue and removed chipGroup.js and
layer-panels.html. The library's icon/action chips now render in
RailChips.vue, rows carry data-library-entry from LayerRows.vue, the
Browse button lives in LayerPanelsView.vue, and the Library group,
rerender() and whileOff controls are re-applied to LayerPanel's
presenter. Library strings are translated for every locale main now
requires.
2026-11-06 12:21:35 +00:00
Said Turksever 976fa6d6ce Add Data Library: live-fetched dataset, tile and pack layers 2026-11-06 12:12:15 +00:00
300 changed files with 16267 additions and 43709 deletions

No files matched your search

+6 -9
View File
@@ -218,7 +218,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 +238,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
@@ -271,15 +268,15 @@ AISSTREAM_API_KEY=
# CCTV_HOUSTON_CAMERAS_URL= # override the Houston TranStar catalog URL # 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: Data Library (DATA LAYERS ▸ Browse data library). Every entry is
# fetched live from its provider; nothing is hosted here. See docs/DATA-LIBRARY.md.
# GEV_LIBRARY_ENABLE= # comma-separated ids of operator opt-in entries to serve (packs use their host's own switch, e.g. CCTV_NYC_ENABLED)
# GEV_LIBRARY_DISABLE= # comma-separated ids to remove from this instance entirely
# 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"). # Also enables voice place search (osm_query, "hospitals in Kathmandu").
OVERPASS_UPSTREAMS= OVERPASS_UPSTREAMS=
+4
View File
@@ -53,6 +53,10 @@ jobs:
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
run: npm run layer-token:check -- --base-ref origin/main run: npm run layer-token:check -- --base-ref origin/main
- name: Check Data Library manifests and share tokens
if: github.event_name == 'pull_request'
run: npm run library:check -- --base-ref origin/main
- name: Run unit tests - name: Run unit tests
run: npm test run: npm test
+4 -3
View File
@@ -128,8 +128,9 @@ 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 a Data Library entry.** A regional or niche source that is not a core layer belongs in the Data Library: one JSON manifest pointing at a live API, provider-hosted file or tile endpoint, with its licence and attribution, and no JavaScript. See [docs/DATA-LIBRARY.md](docs/DATA-LIBRARY.md). Library entries use their own share-token ledger, not the layer tokens below.
- **🛰️ 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.
- **🎙️ 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/`.
- **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md). - **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md).
@@ -228,7 +229,7 @@ ownership and adoption process.
1. Branch off `main`. 1. Branch off `main`.
2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors, plus the [feature gate](#feature-regression-gates) for the area you touched. 2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors, plus the [feature gate](#feature-regression-gates) for the area you touched.
3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/<pr-number>-<slug>.md` instead of editing `CHANGELOG.md` — see [changelog.d/README.md](changelog.d/README.md). Distinct filenames don't conflict; a release assembles the directory into `CHANGELOG.md`. A documentation claim that needs a regression test should assert against `docs/CURRENT-STATE.md` or `README.md`, which a PR still edits directly. 3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/<pr-number>-<slug>.md` instead of editing `CHANGELOG.md` — see [changelog.d/README.md](changelog.d/README.md). Distinct filenames don't conflict; a release assembles the directory into `CHANGELOG.md`. A documentation claim that needs a regression test should assert against `docs/CURRENT-STATE.md` or `README.md`, which a PR still edits directly.
4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead. 4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution (for a Data Library entry, `npm run library:docs` writes its row). **Don't add data you don't have the right to redistribute** — fetch it at runtime instead.
5. Describe what you changed and how you verified it (screenshots welcome for anything visual). 5. Describe what you changed and how you verified it (screenshots welcome for anything visual).
6. If you add a new layer, fill in the new-layer section of the PR template. See [Adding a new layer](#adding-a-new-layer). 6. If you add a new layer, fill in the new-layer section of the PR template. See [Adding a new layer](#adding-a-new-layer).
+119 -9
View File
@@ -65,11 +65,9 @@ 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) |
@@ -124,8 +122,7 @@ 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. - **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.
@@ -173,8 +170,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 +178,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.
@@ -215,6 +209,124 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
--- ---
<!-- data-library:start (generated by npm run library:docs) -->
## Data Library entries (fetched at runtime — not stored in this repo)
Each Data Library entry is a manifest in `src/library/entries/` pointing at a live upstream its provider hosts; see [docs/DATA-LIBRARY.md](docs/DATA-LIBRARY.md). An entry appears on the map only after a viewer adds it from **DATA LAYERS ▸ Browse data library**, and its attribution is registered in the Data attribution popover while it is in use. This table is generated from the manifests: edit the manifest, then run `npm run library:docs`.
| Entry | How it is served | Licence / terms | Attribution |
| --- | --- | --- | --- |
| **Alaska Statewide Imagery (2023)** (`ak-statewide-imagery`, `apps.geo.fpac.usda.gov`) | tiles, bbox, browser-direct | [Public Domain (U.S. Government work)](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USDA / Alaska Geospatial Council" |
| **Amsterdam traffic and ANPR camera positions** (`amsterdam-traffic-cameras`, `api.data.amsterdam.nl`) | dataset, geojson, browser-direct; operator adds `amsterdam-traffic-cameras` to `GEV_LIBRARY_ENABLE` | [Gemeente Amsterdam open data: public unless stated otherwise; no named licence](https://schemas.data.amsterdam.nl/datasets/verkeersinformatiesystemen/dataset) | "Gemeente Amsterdam, verkeersinformatiesystemen register (entry contributed by @Maher-Reven)" |
| **Arkansas Statewide Imagery (2023)** (`ar-statewide-imagery`, `gis.arkansas.gov`) | tiles, bbox, browser-direct | [Public domain — Arkansas GIS Office open data](https://gis.arkansas.gov/) | "Arkansas GIS Office" |
| **Arizona Statewide Imagery (2017)** (`az-statewide-imagery`, `azgeo.az.gov`) | tiles, xyz, browser-direct; operator adds `az-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://azgeo.az.gov/) | "AZGEO" |
| **Bangor, Maine Orthoimagery** (`bangor-me`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `bangor-me` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.bangormaine.gov/) | "City of Bangor GIS" |
| **basemap.at Orthofoto** (`basemap-at-orthofoto`, `mapsneu.wien.gv.at`) | tiles, xyz, browser-direct | [CC BY 4.0](https://basemap.at/nutzungsbedingungen/) | "basemap.at — Datenquelle: basemap.at" |
| **Digitales Orthophoto Bayern (DOP 40 cm, open)** (`bayern-dop40`, `geoservices.bayern.de`) | tiles, bbox, browser-direct | [CC BY 4.0](https://geodaten.bayern.de/opengeodata/) | "© Bayerische Vermessungsverwaltung" |
| **Bismarck–Mandan, North Dakota Aerial Photography** (`bismarck-mandan-nd`, `ndgishub.nd.gov`) | tiles, bbox, browser-direct; operator adds `bismarck-mandan-nd` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.gis.nd.gov/) | "95West Aerial Mapping" |
| **TopPlusOpen basemap (BKG)** (`bkg-topplus-open`, `sgx.geodatenzentrum.de`) | tiles, xyz, browser-direct | [Datenlizenz Deutschland – Namensnennung 2.0](https://www.govdata.de/dl-de/by-2-0) | "© BKG, dl-de/by-2-0" |
| **Florida cameras (FL511)** (`cctv-florida-fl511`, `fl511.com`) | CCTV pack; operator sets `CCTV_FLORIDA_ENABLED=1` | [FL511 content: individual personal use only](https://fl511.com/privacy#disclaimer) | "Florida DOT — FL511.com" |
| **Lithuania road cameras (Via Lietuva)** (`cctv-lithuania`, `eismoinfo.lt`) | CCTV pack; operator sets `CCTV_LITHUANIA_ENABLED=1` | [Via Lietuva open-data statement; camera photo coverage unconfirmed](https://eismoinfo.lt/eismoinfo-backend/content?id=44) | "Via Lietuva / eismoinfo.lt" |
| **New York City cameras (NYC DOT TMC)** (`cctv-nyc-dot`, `webcams.nyctmc.org`) | CCTV pack; operator sets `CCTV_NYC_ENABLED=1` | [NYC DOT camera feed: data-sharing agreement required](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) | "NYC DOT Traffic Management Center" |
| **Connecticut Statewide Imagery (2019)** (`ct-statewide-imagery`, `cteco.uconn.edu`) | tiles, bbox, browser-direct; operator adds `ct-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://ct-deep-gis-open-data-website-ctdeep.hub.arcgis.com/) | "CT ECO — UConn CLEAR / CT DEEP" |
| **Dakota County, Minnesota Air Photo** (`dakota-county-mn`, `gisimg.co.dakota.mn.us`) | tiles, bbox, browser-direct; operator adds `dakota-county-mn` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://gis.co.dakota.mn.us/) | "Dakota County GIS" |
| **District of Columbia Statewide Imagery (2025)** (`dc-statewide-imagery`, `imagery.dcgis.dc.gov`) | tiles, bbox, browser-direct | [Open data — DC public domain terms](https://opendata.dc.gov/pages/terms-of-use) | "Office of the Chief Technology Officer" |
| **Delaware Statewide Imagery (2024)** (`de-statewide-imagery`, `imagery.firstmap.delaware.gov`) | tiles, bbox, browser-direct; operator adds `de-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://firstmap.delaware.gov/) | "This data is meant for use by requested parties. This data cannot be used without express consent.This data is meant for use by requested parties. This data cannot be used without express consent." |
| **Eesti ortofoto (Maa-amet)** (`ee-maa-amet-ortofoto`, `tiles.maaamet.ee`) | tiles, xyz, browser-direct | [Maa-amet open data licence (attribution)](https://geoportaal.maaamet.ee/eng/Spatial-Data/Orthophotos-p309.html) | "© Maa-amet (Estonian Land Board)" |
| **US power plants, 100 MW and up (EIA)** (`eia-power-plants`, `services2.arcgis.com`) | dataset, arcgis, browser-direct | [U.S. Energy Information Administration data: public domain](https://www.eia.gov/about/copyrights_reuse.php) | "U.S. Energy Information Administration (EIA-860, EIA-860M, EIA-923)" |
| **European seabed depth (EMODnet)** (`emodnet-bathymetry`, `tiles.emodnet-bathymetry.eu`) | tiles, xyz, browser-direct | [EMODnet data: free use with attribution](https://emodnet.ec.europa.eu/en/emodnet-data-policy) | "EMODnet Bathymetry Consortium — EMODnet Digital Bathymetry (DTM)" |
| **Superfund sites (US EPA)** (`epa-superfund-sites`, `geopub.epa.gov`) | dataset, arcgis, browser-direct | [U.S. EPA data: public domain](https://www.epa.gov/web-policies-and-procedures/epa-disclaimers) | "U.S. Environmental Protection Agency — Facility Registry Service (EnviroMapper)" |
| **Toxic release facilities (US EPA TRI)** (`epa-toxic-release-facilities`, `geopub.epa.gov`) | dataset, arcgis, browser-direct | [U.S. EPA data: public domain](https://www.epa.gov/web-policies-and-procedures/epa-disclaimers) | "U.S. Environmental Protection Agency — Toxics Release Inventory via the Facility Registry Service" |
| **Home energy ratings, England and Wales (EPC)** (`epc-england-wales`, `s3.us-west-2.amazonaws.com`) | dataset, geoparquet, browser-direct | [Open Government Licence v3.0 (EPC data); OS Open UPRN under OGL](https://epc.opendatacommunities.org/docs/copyright) | "Energy Performance of Buildings Data, DLUHC; geocoded by Addresscloud with OS Open UPRN — contains OS data © Crown copyright and database right; via Source Cooperative" |
| **US building footprints (FEMA USA Structures)** (`fema-usa-structures`, `services2.arcgis.com`) | dataset, arcgis, browser-direct | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | "FEMA Response Geospatial Office and Oak Ridge National Laboratory — USA Structures" |
| **Biodiversity records (GBIF)** (`gbif-occurrence-density`, `api.gbif.org`) | tiles, xyz, browser-direct | [GBIF data use agreement (records under CC0, CC BY or CC BY-NC)](https://www.gbif.org/terms) | "GBIF.org — Global Biodiversity Information Facility" |
| **Disaster alerts (GDACS)** (`gdacs-disaster-alerts`, `www.gdacs.org`) | dataset, geojson, browser-direct | [GDACS terms of use (free, attribution required)](https://www.gdacs.org/About/termofuse.aspx) | "GDACS — Global Disaster Alert and Coordination System (UN, European Commission)" |
| **Orthophotos Canton de Genève (SITG)** (`geneva-sitg-ortho`, `ge.ch`) | tiles, bbox, browser-direct | [SITG open data — free use with attribution](https://ge.ch/sitg/media/sitg/documents/CGU_donnees_libres.pdf) | "© SITG — Etat de Genève" |
| **Population density 2020 (SEDAC GPW)** (`gpw-population-density`, `gibs.earthdata.nasa.gov`) | tiles, xyz, browser-direct | [CC BY 4.0](https://sedac.ciesin.columbia.edu/data/collection/gpw-v4/terms-of-use) | "CIESIN, Columbia University — Gridded Population of the World v4.11, via NASA GIBS" |
| **Grand Forks, North Dakota Aerial Photography** (`grand-forks-nd`, `ndgishub.nd.gov`) | tiles, bbox, browser-direct; operator adds `grand-forks-nd` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.gis.nd.gov/) | "Grand Forks/East Grand Forks Metropolitan Planning Organization (MPO)" |
| **Japan standard map (GSI)** (`gsi-japan-map`, `cyberjapandata.gsi.go.jp`) | tiles, xyz, browser-direct | [GSI Tiles terms (free use with attribution)](https://maps.gsi.go.jp/development/ichiran.html) | "Geospatial Information Authority of Japan (GSI Tiles)" |
| **Japan aerial photos (GSI)** (`gsi-japan-photo`, `cyberjapandata.gsi.go.jp`) | tiles, xyz, browser-direct | [GSI Tiles terms (free use with attribution)](https://maps.gsi.go.jp/development/ichiran.html) | "Geospatial Information Authority of Japan (GSI Tiles)" |
| **Holocene volcanoes (Smithsonian GVP)** (`gvp-holocene-volcanoes`, `webservices.volcano.si.edu`) | dataset, geojson, proxied | [Smithsonian Terms of Use (personal, educational, non-commercial use; cite the Global Volcanism Program)](https://www.si.edu/termsofuse) | "Global Volcanism Program, Smithsonian Institution — Volcanoes of the World" |
| **Iowa Statewide Imagery (2023)** (`ia-statewide-imagery`, `ortho.gis.iastate.edu`) | tiles, bbox, browser-direct; operator adds `ia-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://geodata.iowa.gov/) | "USDA NAIP, Iowa State University GIS Facility" |
| **Ortofoto de Catalunya 25 cm** (`icgc-orto-catalunya`, `geoserveis.icgc.cat`) | tiles, xyz, browser-direct | [CC BY 4.0](https://www.icgc.cat/en/Ambits-tematics/Ortofoto) | "© Institut Cartogràfic i Geològic de Catalunya" |
| **Idaho Statewide Imagery (2023)** (`id-statewide-imagery`, `insideidaho.org`) | tiles, bbox, browser-direct | [Public domain / open — INSIDE Idaho terms](https://insideidaho.org/) | "United States Department of Agriculture, Farm Production and Conservation Business Center, Geospatial Enterprise Operations (FPAC-BC-GEO)" |
| **France aerial photos (IGN)** (`ign-orthophotos-france`, `data.geopf.fr`) | tiles, xyz, browser-direct | [Licence Ouverte / Etalab 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/) | "© IGN — Géoplateforme" |
| **Illinois Statewide Imagery** (`il-statewide-imagery`, `data.isgs.illinois.edu`) | tiles, bbox, browser-direct; operator adds `il-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://clearinghouse.isgs.illinois.edu/) | "Illinois State Geological Survey / IDOT" |
| **Maritime chokepoints (IMF PortWatch)** (`imf-portwatch-chokepoints`, `services9.arcgis.com`) | dataset, arcgis, browser-direct; operator adds `imf-portwatch-chokepoints` to `GEV_LIBRARY_ENABLE` | [IMF PortWatch terms; attribution required (reuse terms under review)](https://www.imf.org/external/terms.htm) | "IMF PortWatch (portwatch.imf.org)" |
| **Port disruptions (IMF PortWatch)** (`imf-portwatch-disruptions`, `services9.arcgis.com`) | dataset, arcgis, browser-direct; operator adds `imf-portwatch-disruptions` to `GEV_LIBRARY_ENABLE` | [IMF PortWatch terms; attribution required (reuse terms under review)](https://www.imf.org/external/terms.htm) | "IMF PortWatch (portwatch.imf.org), with GDACS event data" |
| **Indiana Statewide Imagery (2016)** (`in-statewide-imagery`, `di-ingov.img.arcgis.com`) | tiles, bbox, browser-direct | [CC0 1.0 Universal (public domain dedication)](https://creativecommons.org/publicdomain/zero/1.0/legalcode) | "Indiana Geographic Information Office (CC0)" |
| **Surface water occurrence (JRC)** (`jrc-surface-water`, `storage.googleapis.com`) | tiles, xyz, browser-direct | [Copernicus programme: free, without restriction of use](https://global-surface-water.appspot.com/download) | "Source: EC JRC/Google — Pekel et al. (2016), Global Surface Water" |
| **Norway counties and municipalities (Kartverket)** (`kartverket-admin-units`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Norway cadastral map (Kartverket)** (`kartverket-cadastre`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Norway sea depths (Kartverket)** (`kartverket-depth`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Norway hillshade (Kartverket)** (`kartverket-hillshade`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Norway nautical charts (Kartverket)** (`kartverket-nautical-charts`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Norway terrain relief (Kartverket DTM)** (`kartverket-terrain-relief`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Norway topographic map (Kartverket)** (`kartverket-topo`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Norway topographic map, greyscale (Kartverket)** (`kartverket-topo-grey`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Norway topographic raster map (Kartverket)** (`kartverket-toporaster`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Kansas Statewide Imagery (2021)** (`ks-statewide-imagery`, `services.kgs.ku.edu`) | tiles, bbox, browser-direct; operator adds `ks-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.kansasgis.org/) | "FSA, USDA," |
| **Kentucky Statewide Imagery** (`ky-statewide-imagery`, `kyraster.ky.gov`) | tiles, bbox, browser-direct | [Public domain with attribution](https://kygeonet.ky.gov/) | "KyFromAbove" |
| **Lake County, Indiana Aerial Imagery** (`lake-county-in`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `lake-county-in` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.lakecountyin.org/) | "Lake County Surveyor's Office" |
| **Lawrence, Kansas Aerial Photos** (`lawrence-ks`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `lawrence-ks` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://gis-lawrenceks.opendata.arcgis.com/) | "City of Lawrence GIS" |
| **Lee County, Florida Aerial Imagery** (`lee-county-fl`, `gisimageserver.leegov.com`) | tiles, bbox, browser-direct; operator adds `lee-county-fl` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.leegov.com/gis) | "Eagle View, Lee County Property Appraiser, Lee County GIS" |
| **Loudoun County, Virginia Imagery** (`loudoun-county-va`, `logis.loudoun.gov`) | tiles, bbox, browser-direct; operator adds `loudoun-county-va` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://geohub-loudoungis.opendata.arcgis.com/) | "Loudoun County GIS" |
| **Louisville / LOJIC, Kentucky Imagery** (`louisville-lojic-ky`, `services.lojic.org`) | tiles, bbox, browser-direct; operator adds `louisville-lojic-ky` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.lojic.org/) | "LOJIC, USDA-NAIP" |
| **Orthophoto officielle du Luxembourg** (`lu-ortho-latest`, `wmts1.geoportail.lu`) | tiles, xyz, browser-direct | [CC0 1.0 (Licence ouverte Luxembourg)](https://data.public.lu/en/datasets/orthophoto-officelle-du-grand-duche-de-luxembourg-edition-2023/) | "Administration du cadastre et de la topographie, Luxembourg (CC0)" |
| **Massachusetts Statewide Imagery** (`ma-statewide-imagery`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `ma-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://gis.data.mass.gov/) | "MassGIS, Commonwealth of Massachusetts" |
| **Geologic map (Macrostrat)** (`macrostrat-geology`, `tiles.macrostrat.org`) | tiles, xyz, browser-direct | [CC BY 4.0 (Macrostrat; source maps credited by their publishers)](https://macrostrat.org/about) | "Macrostrat — geologic maps from their original publishers" |
| **Global terrain shaded relief (Mapterhorn)** (`mapterhorn-terrain`, `tiles.mapterhorn.com`) | tiles, xyz, browser-direct | [Open data, per source (mostly CC BY 4.0 and public domain)](https://mapterhorn.com/attribution) | "© Mapterhorn and its open elevation sources" |
| **MARIS Imagery, Mississippi** (`maris-ms`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `maris-ms` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.gis.ms.gov/) | "MARIS" |
| **Maryland Statewide Imagery** (`md-statewide-imagery`, `mdgeodata.md.gov`) | tiles, bbox, browser-direct; operator adds `md-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://imap.maryland.gov/) | "Maryland iMAP" |
| **Maine Statewide Imagery** (`me-statewide-imagery`, `gis.maine.gov`) | tiles, bbox, browser-direct; operator adds `me-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.maine.gov/geolib/) | "USDA Aerial Photography Field Office" |
| **Michigan Statewide Imagery (2024)** (`mi-statewide-imagery`, `imagery.michigan.gov`) | tiles, bbox, browser-direct; operator adds `mi-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://gis-michigan.opendata.arcgis.com/) | "State of Michigan" |
| **Minnesota Statewide Imagery** (`mn-statewide-imagery`, `imageserver.gisdata.mn.gov`) | tiles, bbox, browser-direct | [Public domain / open — MnGeo terms](https://www.mngeo.state.mn.us/) | "MnGeo / State of Minnesota" |
| **Mountain Village, Colorado Orthoimagery** (`mountain-village-co`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `mountain-village-co` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.townofmountainvillage.com/) | "Town of Mountain Village" |
| **Mississippi Statewide Imagery** (`ms-statewide-imagery`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `ms-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.gis.ms.gov/) | "USDA-FSA-APFO" |
| **Montana Statewide Imagery (2023)** (`mt-statewide-imagery`, `gisservice.mt.gov`) | tiles, bbox, browser-direct | [Public domain — Montana State Library open data](https://msl.mt.gov/geoinfo/) | "Montana State Library" |
| **NAIP — National Agriculture Imagery Program** (`naip`, `imagery.nationalmap.gov`) | tiles, bbox, browser-direct | [Public Domain (U.S. Government work)](https://naip-usdaonline.hub.arcgis.com/) | "USDA NAIP" |
| **Earth at night (NASA Black Marble 2016)** (`nasa-black-marble`, `gibs.earthdata.nasa.gov`) | tiles, xyz, browser-direct | [NASA data: U.S. public domain; no endorsement implied](https://www.earthdata.nasa.gov/engage/open-data-services-software-policies) | "NASA Earth Observatory / GIBS — VIIRS Black Marble" |
| **NASA EONET open natural events** (`nasa-eonet-events`, `eonet.gsfc.nasa.gov`) | dataset, geojson, browser-direct | [NASA data: U.S. public domain; no endorsement implied](https://www.nasa.gov/nasa-brand-center/images-and-media/) | "NASA Earth Observatory Natural Event Tracker (EONET)" |
| **North Carolina Statewide Imagery (2023)** (`nc-statewide-imagery`, `services.nconemap.gov`) | tiles, bbox, browser-direct | [Public domain — NC OneMap terms of use](https://www.nconemap.gov/pages/terms) | "NC911, NCCGIA" |
| **North Central Texas (NCTCOG) Ortho** (`nctcog-tx`, `mapitimage.tarrantcounty.com`) | tiles, bbox, browser-direct; operator adds `nctcog-tx` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.nctcog.org/) | "North Central Texas Council of Governments" |
| **North Dakota Statewide Imagery (2013)** (`nd-statewide-imagery`, `ndgishub.nd.gov`) | tiles, xyz, browser-direct; operator adds `nd-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.gis.nd.gov/) | "ND Dept. of Transportation, ND Dept. of Emergency Services, and the ND GIS Hub" |
| **New Orleans Regional Planning Commission Imagery** (`new-orleans-rpc-la`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `new-orleans-rpc-la` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.norpc.org/) | "Regional Planning Commission for Jefferson, Orleans, Plaquemines, St. Bernard, St. Charles, St. John the Baptist, St. Tammany, and Tangipahoa Parishes (RPC)" |
| **New Hampshire Statewide Imagery (2022)** (`nh-statewide-imagery`, `nhgeodata.unh.edu`) | tiles, bbox, browser-direct; operator adds `nh-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — NH GRANIT terms](https://www.granit.unh.edu/) | "NH GRANIT, University of New Hampshire" |
| **New Jersey Statewide Imagery (2015)** (`nj-statewide-imagery`, `maps.nj.gov`) | tiles, xyz, browser-direct; operator adds `nj-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — State of New Jersey provides data 'as is'](https://njgin.nj.gov/) | "Project definition, funding requests, and data distribution by NJ Office of Information Technology, Office of Geographic Information Systems (NJOIT - OGIS). Funding for the imagery project provided by" |
| **US tide stations (NOAA CO-OPS)** (`noaa-tide-stations`, `api.tidesandcurrents.noaa.gov`) | dataset, json, browser-direct | [NOAA data: U.S. public domain](https://tidesandcurrents.noaa.gov/disclaimers.html) | "NOAA Center for Operational Oceanographic Products and Services (CO-OPS)" |
| **Digitale Orthophotos NRW (DOP 10 cm)** (`nrw-dop`, `www.wms.nrw.de`) | tiles, bbox, browser-direct | [Datenlizenz Deutschland – Zero (public-domain-like)](https://www.govdata.de/dl-de/zero-2-0) | "Geobasis NRW (dl-de/zero-2-0)" |
| **NSW Imagery (Six Maps)** (`nsw-imagery`, `maps.six.nsw.gov.au`) | tiles, xyz, browser-direct | [CC BY 4.0](https://www.spatial.nsw.gov.au/products_and_services/web_services) | "© Spatial Services NSW" |
| **US bridges and their condition (NBI)** (`ntad-national-bridges`, `services.arcgis.com`) | dataset, arcgis, browser-direct | [U.S. government work: unrestricted public use](https://www.bts.gov/ntad) | "USDOT Bureau of Transportation Statistics — National Transportation Atlas Database, National Bridge Inventory" |
| **Northwest Arkansas (Benton & Washington County) Imagery** (`nwa-benton-washington-ar`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `nwa-benton-washington-ar` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.nwarpc.org/) | "NWARPC; Washington County; Benton County" |
| **US weather alerts with drawn areas (NWS)** (`nws-active-alerts`, `api.weather.gov`) | dataset, geojson, browser-direct | [NWS data: U.S. public domain](https://www.weather.gov/disclaimer) | "NOAA National Weather Service" |
| **New York Statewide Imagery (2019)** (`ny-statewide-imagery`, `orthos.its.ny.gov`) | tiles, xyz, browser-direct | [Public domain — NYS open data](https://data.gis.ny.gov/) | "USDA; NYS ITS Geospatial Services" |
| **Oakland County, Michigan Ortho Imagery** (`oakland-county-mi`, `gisservices.oakgov.com`) | tiles, bbox, browser-direct; operator adds `oakland-county-mi` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://oakgov.maps.arcgis.com/) | "Oakland County GIS" |
| **Ohio Statewide Imagery** (`oh-statewide-imagery`, `maps.ohio.gov`) | tiles, xyz, browser-direct | [Public domain — Ohio open data](https://maps.ohio.gov/) | "Ohio Geographically Referenced Information Program (OGRIP)" |
| **Seamarks (OpenSeaMap)** (`openseamap-seamarks`, `tiles.openseamap.org`) | tiles, xyz, browser-direct | [CC BY-SA 2.0; data © OpenStreetMap contributors (ODbL)](https://openseamap.org/index.php?id=imprint&L=1) | "© OpenSeaMap contributors, CC BY-SA; data © OpenStreetMap contributors" |
| **Oregon Statewide Imagery (2000)** (`or-statewide-imagery`, `imagery.oregonexplorer.info`) | tiles, bbox, browser-direct | [Public domain — Oregon open data](https://www.oregon.gov/geo/) | "Oregon, GEO, DAS" |
| **Pennsylvania Statewide Imagery** (`pa-statewide-imagery`, `imagery.pasda.psu.edu`) | tiles, bbox, browser-direct | [Public domain — PASDA open data](https://www.pasda.psu.edu/) | "PASDA / PAMAP, Commonwealth of Pennsylvania" |
| **Netherlands aerial photos (PDOK)** (`pdok-aerial-netherlands`, `service.pdok.nl`) | tiles, xyz, browser-direct | [CC BY 4.0](https://www.pdok.nl/introductie/-/article/luchtfoto-pdok) | "Beeldmateriaal Nederland via PDOK" |
| **Ortofotomapa Polski (GUGiK)** (`pl-ortofotomapa`, `mapy.geoportal.gov.pl`) | tiles, bbox, browser-direct | [Polish open geodata (free reuse, Prawo geodezyjne art. 40a)](https://www.geoportal.gov.pl/) | "© GUGiK / geoportal.gov.pl" |
| **PNOA Máxima Actualidad** (`pnoa-ma`, `www.ign.es`) | tiles, bbox, browser-direct | [CC BY 4.0 (compatible) — Orden FOM/2807/2015](https://www.scne.es/productos.html) | "PNOA © Instituto Geográfico Nacional de España" |
| **Portland Metro (RLIS), Oregon Aerial Photos** (`portland-metro-or`, `utility.arcgis.com`) | tiles, xyz, browser-direct; operator adds `portland-metro-or` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://rlisdiscovery.oregonmetro.gov/) | "Oregon Metro RLIS" |
| **Ortofotos de Portugal Continental (DGT)** (`pt-dgt-ortos`, `ortos.dgterritorio.gov.pt`) | tiles, bbox, browser-direct | [CC BY 4.0](https://www.dgterritorio.gov.pt/) | "© Direção-Geral do Território" |
| **Rhode Island Statewide Imagery (2019)** (`ri-statewide-imagery`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `ri-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — RIGIS terms](https://www.rigis.org/) | "URI EDC, RIGIS" |
| **Switzerland aerial photos (SWISSIMAGE 10 cm)** (`swisstopo-swissimage`, `wmts.geo.admin.ch`) | tiles, xyz, browser-direct | [swisstopo free geodata (OGD): free use, including commercial, with source credit](https://www.swisstopo.admin.ch/en/terms-of-use-free-geodata-and-geoservices) | "©swisstopo — SWISSIMAGE 10 cm" |
| **Texas Statewide Imagery (2022)** (`tx-statewide-imagery`, `imagery.geographic.texas.gov`) | tiles, bbox, browser-direct | [Public domain — TxGIO open data](https://geographic.texas.gov/) | "Texas Geographic Information Office (TxGIO)" |
| **Union County, Ohio Aerial Imagery** (`union-county-oh`, `www7.co.union.oh.us`) | tiles, bbox, browser-direct; operator adds `union-county-oh` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://union-county-auditor-unionco.hub.arcgis.com/) | "Union County / City of Marysville GIS" |
| **US rivers and lakes (USGS)** (`usgs-hydrography`, `basemap.nationalmap.gov`) | tiles, xyz, browser-direct | [USGS The National Map: U.S. public domain](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USGS The National Map — National Hydrography Dataset" |
| **US topographic map (USGS)** (`usgs-topo`, `basemap.nationalmap.gov`) | tiles, xyz, browser-direct | [USGS The National Map: U.S. public domain](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USGS The National Map" |
| **Virginia Statewide Imagery** (`va-statewide-imagery`, `vginmaps.vdem.virginia.gov`) | tiles, xyz, browser-direct; operator adds `va-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — VGIN VBMP terms](https://vgin.vdem.virginia.gov/) | "Virginia Geographic Information Network (VGIN)" |
| **Orthofotomozaïek Vlaanderen (winter)** (`vlaanderen-omw`, `geo.api.vlaanderen.be`) | tiles, bbox, browser-direct | [Vlaanderen model licence for free reuse](https://www.vlaanderen.be/digitaal-vlaanderen/onze-oplossingen/orthofotomozaieken) | "© Digitaal Vlaanderen" |
| **Vermont Statewide Imagery** (`vt-statewide-imagery`, `maps.vcgi.vermont.gov`) | tiles, bbox, browser-direct | [Public domain — Vermont open data](https://vcgi.vermont.gov/) | "Vermont Center for Geographic Information (VCGI)" |
| **Orthophotos de Wallonie** (`wallonie-ortho`, `geoservices.wallonie.be`) | tiles, bbox, browser-direct | [Conditions ouvertes SPW (attribution)](https://geoportail.wallonie.be/catalogue-donnees) | "© Service public de Wallonie" |
| **Wisconsin Statewide Imagery (2020)** (`wi-statewide-imagery`, `di-wisdot.img.arcgis.com`) | tiles, bbox, browser-direct; operator adds `wi-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — WisconsinView / state open data](https://www.wisconsinview.org/) | "WisDOT - NAIP 2020" |
| **Global power plants (WRI)** (`wri-global-power-plants`, `raw.githubusercontent.com`) | dataset, csv, browser-direct | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | "World Resources Institute, Global Power Plant Database (2021)" |
| **West Virginia Statewide Imagery** (`wv-statewide-imagery`, `services.wvgis.wvu.edu`) | tiles, bbox, browser-direct; operator adds `wv-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — WV GIS Technical Center terms](https://wvgis.wvu.edu/) | "WV GIS Technical Center, West Virginia University" |
<!-- data-library:end -->
## Bundled snapshots ## Bundled snapshots
Static datasets shipped in the repo for an out-of-the-box experience. **None are MIT** — each keeps its own license (see the carve-out in [LICENSE](LICENSE)). Each folder also has its own provenance README. Static datasets shipped in the repo for an out-of-the-box experience. **None are MIT** — each keeps its own license (see the carve-out in [LICENSE](LICENSE)). Each folder also has its own provenance README.
@@ -226,8 +338,6 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) | | **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map | | **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) |
+4 -4
View File
@@ -440,7 +440,7 @@ 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), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **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 | 🟢 |
@@ -485,9 +485,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token), **Google Street View** (your Google Maps key), **KartaView** and **Panoramax** (no key). > 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 +639,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
+3 -3
View File
@@ -69,14 +69,14 @@ 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.
- **The Data Library proxy fetches manifest upstreams only.** `/api/library/data/<id>`, `/api/library/tile/<id>/<z>/<x>/<y>` and `/api/library/index/<id>` (a partitioned GeoParquet entry's file index, built from an S3 ListObjectsV2 listing and one ranged footer read per file, cached in memory for the entry's `cacheSeconds`) resolve the id against the validated manifests in `src/library/entries/`; the browser never supplies a URL, and every derived request must stay on the manifest's https host. Redirects are refused, dataset bodies are capped while streaming at the manifest's `maxBytes` (at most 32 MiB) and tiles at 2 MiB of `image/png`, `image/jpeg` or `image/webp`, responses are cached in memory only (at most 512 tiles), requests are rate-limited per client, and upstream error text is never returned. A keyed entry must be proxied; its key is read from this server's environment through the key registry and its routes refuse cross-site and forwarded requests like the other cost-bearing routes. Browser-direct entries are fetched by the browser with no cookies or referrer.
- **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. - **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. 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. - **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.
- **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.
+17 -2
View File
@@ -1,8 +1,8 @@
# Third-party software notices # Third-party software notices
The project depends on third-party npm packages whose own licenses apply. This The project depends on third-party npm packages whose own licenses apply. This
file records the packages added for the browser-local SDR feature and the file records the packages added for the browser-local SDR feature, the
Street Level viewer; the complete resolved dependency inventory remains in Street Level viewer and the Data Library's GeoParquet reader; the complete resolved dependency inventory remains in
`package-lock.json`. `package-lock.json`.
## Web RTL-SDR ## Web RTL-SDR
@@ -45,3 +45,18 @@ Street Level viewer; the complete resolved dependency inventory remains in
- Source: <https://github.com/mrdoob/three.js> - Source: <https://github.com/mrdoob/three.js>
- License: MIT - License: MIT
- License text: <https://github.com/mrdoob/three.js/blob/dev/LICENSE> - License text: <https://github.com/mrdoob/three.js/blob/dev/LICENSE>
## hyparquet
- Package: `hyparquet` 1.31.2 (loaded on demand when a Data Library
GeoParquet entry is switched on)
- Author: Hyperparam
- Source: <https://github.com/hyparam/hyparquet>
- License: MIT
## fzstd (hyparquet ZSTD decompressor)
- Package: `fzstd` 0.1.1
- Author: Copyright (c) 2020 Arjun Barrett
- Source: <https://github.com/101arrowz/fzstd>
- License: MIT
@@ -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,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,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,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).
+29
View File
@@ -0,0 +1,29 @@
- Add the Data Library. New regional and niche sources no longer join the
Data Layers panel: **Browse data library** at its foot lists live entries
(a manifest each in `src/library/entries/`, fetched from the provider, never
bundled) with their licence, coverage and what this instance needs to serve
them, and **Add** puts one under a Library heading. Datasets (GeoJSON, CSV,
ArcGIS), raster tiles and host-layer packs are supported; selections persist
and travel in share links as `lib=`, separate from the layer tokens; one
generic `/api/library` proxy serves entries without CORS. Seeded with NASA
EONET events, EIA and WRI power plants, NWS alerts, NOAA tide stations,
Smithsonian volcanoes, NASA Black Marble, OpenSeaMap seamarks, and the
opt-in IMF PortWatch and CCTV packs, plus the City of Amsterdam's
traffic and ANPR camera positions (opt-in; contributed by @Maher-Reven in
#251). FEMA USA Structures and the
National Bridge Inventory load what is in view once you zoom in, and
Addresscloud's EPC ratings stream from Source Cooperative's GeoParquet on
S3, coloured A–G with live legend counts. Open tile overlays (geology,
biodiversity, population, surface water, national aerial photos and maps)
join GDACS alerts and EPA Superfund and TRI facilities. 64 aerial photo
layers from the Open Imagery Register (national programmes across Europe,
NSW, NAIP and US state, county and city services, WMS and ArcGIS export
included) show their resolution, true-ortho flag, years flown and refresh
cycle in Browse; aerial photos stay above every other drape, are clipped to
their coverage, and the providers' blank fill no longer whitens the globe.
Kartverket's open Norwegian maps (topographic, nautical charts, hillshade,
terrain relief, sea depths, boundaries and the cadastre) and Mapterhorn's
global terrain, drawn in the browser as shaded relief with an elevation
legend, are in the library too. Stat tiles head the dialog. A library layer's row has
Zoom to layer and Remove icons, and the dialog has Zoom and a confirmed
Remove all (asturksever, #1604).
@@ -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).
-12
View File
@@ -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).
-3
View File
@@ -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,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).
-4
View File
@@ -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).
-23
View File
@@ -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
-155
View File
@@ -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).
+262 -180
View File
@@ -134,7 +134,7 @@ existing batch size and one bounded upstream chunk. Larger requests return HTTP
413 before disk-cache loading or upstream work; malformed coordinates remain 400. 413 before disk-cache loading or upstream work; malformed coordinates remain 400.
The browser continues to split larger workloads into sequential 64-point requests. The browser continues to split larger workloads into sequential 64-point requests.
Traffic holds continuous rendering only while enabled with shown animated dots, a pulsing jam heat-line, or road lines whose geometry is still building (replaced lines stay drawn until their successors are ready, so a rebuild never blinks); hidden dots (`dots` off) and settled flow lines hold nothing. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading. Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
@@ -250,6 +250,7 @@ style listeners. Synthetic fixtures cover privacy, acquisition, rendering,
selection and card placement. Deterministic operation checks run in `npm test`; selection and card placement. Deterministic operation checks run in `npm test`;
wall-clock budgets run in `npm run test:perf`. All 11 locales are supported. wall-clock budgets run in `npm run test:perf`. All 11 locales are supported.
## Prohibited & Restricted Airspace — FAA special-use airspace ## Prohibited & Restricted Airspace — FAA special-use airspace
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`, The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
@@ -325,6 +326,206 @@ provider registration rejects compositions that cannot fit rather than truncatin
identities. A malformed or duplicate entry invalidates the complete custom-switch identities. A malformed or duplicate entry invalidates the complete custom-switch
field. Existing whole-link size limits still apply. field. Existing whole-link size limits still apply.
## Data Library — October 21, 2026
DATA LAYERS keeps its curated core layers; everything else lives in the Data
Library, opened from **Browse data library** at the foot of the panel. An entry
is a manifest in `src/library/entries/` that points at a live upstream its
provider hosts — a query API, a provider-hosted static file or a tile endpoint.
Nothing an entry describes is bundled, so a self-hosted instance serves every
entry without hosting data. Manifests are validated strictly when
`src/library/catalog.js` loads (unknown fields are errors), so an invalid entry
fails the unit suite. Authoring guide: [DATA-LIBRARY.md](DATA-LIBRARY.md).
Three kinds ship. A `dataset` (GeoJSON, GeoJSON Lines, CSV with lat/lon
columns, a JSON record list read from `source.records` with named or guessed
lat/lon fields, or a paged ArcGIS FeatureServer query) renders through the same
GeoJSON layer as Datacenters and Dams, with the manifest's label, up to two
card fields and an optional `rank` field that orders a capped set and feeds the
layer's level-of-detail priority (0–600, below the named/unnamed gap). A capped
dataset reads PARTIAL. A dataset with `source.view` is view-bounded,
for layers too large to load whole (FEMA USA Structures: ~135 million
footprints): below `view.maxAltitudeM` it queries ArcGIS for what intersects a
window around the point the camera looks at (half the camera height each way,
330 m – 5.5 km, clipped to the visible rectangle), one page per view after the
camera settles, replacing the previous set only when the new one is ready and
aborting a superseded request; above it the row reads "zoom in to load this
layer" and nothing is fetched. Footprints are ground-clamped polygons (draped on
Google 3D tiles too), without outlines. Zoom to layer lands straight above the
manifest's `view.sample` area at 60 % of the altitude limit. View-bounded
entries are ArcGIS or GeoParquet, and browser-direct, in this build.
`geoparquet` datasets are read in the browser with `hyparquet` (with `fzstd`
for ZSTD), imported lazily on first use; only the columns an entry uses are
decoded, and WKB geometry arrives as GeoJSON. A partitioned set
(`source.partitions`: an S3 ListObjectsV2 `listUrl` and a `keyBase`, both on the
source host) must be view-bounded. Its index — each file's GeoParquet footer
bbox, read with one range request per file — comes from `/api/library/index/<id>`
(built once per `cacheSeconds` and shared), or is built in the browser and kept
in `gev:library:index:<id>` on a static build. Footer bboxes can be stretched
by stray geocodes, so candidates are ranked by how much of each file's bbox
the view covers, read in parallel batches of four (up to eight per view)
until a batch has features in view, and each file read once teaches its real
extent (the box holding 98 % of its points, `gev:library:extent:<id>`), so
later views skip files that do not cover them. The last four decoded files
stay in memory. `display.colorBy` colours each feature by a field's value (a
dotted path such as `categories.0.title` is flattened into the feature's
`__gevColorKey`): view-bounded layers draw 6 px dots or filled polygons, other
datasets keep the GeoJSON layer's stems, cards and level of detail with a
per-feature colour (`featureColor` in `localGeojsonCore.js`). Values sharing a
`colorBy.labels` label form one legend group (coal, gas and oil are "Fossil");
values the manifest does not colour draw in `display.color` and count as
Other. The row legend lists each group with its count (in view, or in the
loaded set), one item per line as Fire Perimeters' does. Status-like fields
(GDACS and PortWatch alert levels, NWS severity, bridge condition) use the
fixed status palette (good #0ca30c, warning #fab219, serious #ec835a,
critical #d03b3b); categorical ones (fuel, EONET category, building
occupancy) use at most four hues that pass all-pairs colour-vision checks on
the dark globe, plus a neutral Other. Tile layers carry a static
`display.legend` copied from the provider's own scale (GBIF classic.poly,
JRC occurrence, GIBS GPW, Macrostrat's ICS period colours); EMODnet publishes
no machine-readable scale and has none. Aerial photo layers (PDOK, IGN, GSI
photos, swisstopo SWISSIMAGE) set `display.outline`: their covered countries'
borders draw as a thin white ground-clamped line, so the imagery's edge is
plain, with a "Coverage outline" legend item. Borders come from Natural
Earth's 1:50m admin-0 countries (public domain, fetched live once per page and
credited); `countries` draws every part (Japan's islands, Liechtenstein beside
Switzerland), `coverage` only the parts inside `coverage.bbox` (the European
Netherlands, metropolitan France). A failed border fetch costs only the
outline. The same layers set `display.aerial`: they drape above every other
imagery layer and raise themselves again whenever another drape is added, so a
basemap or weather overlay switched on later goes underneath. Each is
`source.bounded` (tiles requested inside `coverage.bbox` only) and names its
provider's no-imagery fill in `display.noDataColor`, drawn transparent through
Cesium's colour-to-alpha (max channel distance 0.02): measured fills are PDOK
and swisstopo white, IGN's flat ocean blue (#0a3b59) and GSI's ocean grey
(#343434). Without both, PDOK's white fill covered the whole globe. A 4xx tile
answer counts as "no imagery here", never as a tile error. IGN's overseas
departments are outside its single bbox and are not shown. The aerial set now holds 68
entries: 64 imported from the Open Imagery Register (national programmes in
Austria, Belgium, Germany, Estonia, Spain, Luxembourg, Poland, Portugal,
Geneva, Australia's NSW, and 51 US services: NAIP plus state, county and
city programmes) plus PDOK, IGN, GSI and SWISSIMAGE, each with an `imagery`
block (type, published resolution, true ortho, years flown, refresh) that
Browse shows as a spec list. Tiles take `xyz` (with `{reverseY}` for TMS) or
`bbox` templates (`{bbox-epsg-3857}`: WMS GetMap and ArcGIS export, filled
by Cesium in the browser and by the proxy from z/x/y). Sub-national entries
draw a `bbox` rectangle outline without fetching the country borders. 35
entries whose licence the register gives only as "see publisher terms" are
operator opt-in. Norway comes from Kartverket's open
services (CC BY 4.0, "© Kartverket"; its zoom 12–20 Geovekst data may be shown
in live services but not copied): the topographic, greyscale, raster-topo and
nautical-chart caches (`xyz`), and WMS hillshade, DTM colour relief, sea depths
(from zoom 11, Kartverket's 16-class depth legend), county and municipality
boundaries, and the cadastre (from zoom 14). Norge i bilder needs
authentication and is not included. `mapterhorn-terrain` is the first
`source.encoding: terrarium` entry: Mapterhorn's global 512 px terrain-RGB
tiles are decoded per tile in the browser (`src/library/relief.js`) into
shaded relief with a six-class elevation legend in metres. A `tiles` entry (`{z}/{x}/{y}` template) drapes on the
active imagery host — the globe, or the Google 3D tileset — and follows map
stack switches; with no host it shows the existing "choose a globe map"
guidance, and its row reads DRAPED rather than a count. A `pack` describes an
existing host-layer source pack (today the opt-in CCTV packs): it is
informational in Browse, names the host's own switch (`CCTV_NYC_ENABLED=1` and
so on) and offers no Add. The `provider` kind from the RFC is refused by the
validator until it ships.
Entries register their layer only when a viewer adds them, after the core
catalog is sealed: `LayerLifecycle.registerLibraryLayer` accepts only ids in
the `lib-<entry id>` namespace and only after `finalizeRegistrations`, and
`unregisterLibraryLayer` destroys them. Library layers never enter
`LAYER_STATE_REGISTRY` or the core token ledger. The panel groups them under a
**Library** heading after every core group and rebuilds its rows on a
`registrations` activity. Add switches the layer on; Remove destroys it.
A library layer's row carries two icon chips, on or off (the panel shows
row controls for an off layer only for Data Library rows, whose chips are
marked `whileOff`): **Zoom to layer**
(`zoom_in_map`) flies to the extent of the loaded features, or a tiles entry's
declared coverage, taking the shorter way round the antimeridian and showing the
whole globe for world-scale sets; **Remove from Data Layers** (`delete`)
removes the entry. Zoom on a layer that is off switches it on first (a
dataset only knows its extent once loaded). Zoom runs as an immediate
navigation (`layer`), so Cockpit
refuses it with its own message and the current follow owner is released first;
reduced motion flies instantly. Icon chips (`chipGroup.js` `icon`/`action`) use
their label as accessible name and tooltip and carry no `aria-pressed`.
This browser's selection persists in `gev:library:v1` (`{ added, on }`), written
only for explicit (user, voice, tool) changes, like core layer state, so a
Context session's temporary changes are not remembered. Share links carry the
switched-on entries as `lib=<t>.<t>` with the permanent two-character tokens in
`src/library/ledger.json` (append-only; packs take none). A link's entries join
the viewer's library and, as for core layers, the link alone decides what is
on. Unknown tokens are skipped. The live hash follows the existing
initial-restore suppression.
Browser-direct entries are fetched without cookies or a referrer, redirects
refused, bodies capped while streaming. Proxied entries go through
`/api/library` (`server/providers/library.js`): `/catalog` reports each entry's
availability and reason (`operator-opt-in`, `operator-disabled`, `key-missing`)
without secrets; `/data/<id>` and `/tile/<id>/<z>/<x>/<y>` reach only the
manifest's fixed upstream (the browser names an id, never a URL), refuse
redirects and off-manifest hosts, cap dataset bodies at the manifest's
`maxBytes` (≤ 32 MiB) and tiles at 2 MiB of `image/png|jpeg|webp`, cache per the
manifest's `cacheSeconds` (60 s – 24 h; at most 512 tiles), serve the last good
dataset as stale on failure, rate-limit per client, and never return upstream
text. A keyed entry must be proxied; its key comes from the server environment
through the key registry and its routes use the strict cross-site gate, while
keyless routes use the keyless gate so they work behind a reverse proxy.
Operators opt entries in with `GEV_LIBRARY_ENABLE` and remove them with
`GEV_LIBRARY_DISABLE`. A static build with no server still serves every
browser-direct, non-opt-in entry; proxied ones say they need the app's server.
The Browse dialog is a `dialog[data-panel-surface]`: modal, labelled, search
focused on open, Escape and backdrop dismiss and return focus to Browse, and it
closes when clean view, recording or Cockpit hides the panels. Its surface is
frosted glass (a lighter `--glass-bg` than the rails, a 22 px backdrop blur
clipped to the theme's frame, a barely dimmed backdrop), so the globe shows
through without the HUD text behind it competing. An added entry that is on
offers ZOOM, which closes the dialog and flies to it. Above the intro, four
stat tiles summarise the library: Datasets (every entry, broken down by kind),
Themes (how many distinct themes the entries cover), Added (and how many are on
the map), and Map features (features loaded by dataset layers that are on;
tiles have no count).
They refresh with the dialog and once a second while it is open, so counts
land as datasets finish loading. The line beside **REMOVE ALL** answers the
filter ("Showing 2 of 8"). **REMOVE ALL** removes every added entry; its first
press arms a confirm for four seconds. It lists
addable entries first, then entries this instance would need to switch on, then
packs, with kind, status, opt-in or key badges, provider, coverage and a licence
link, and filters by text, theme and "only what this instance serves".
Archived entries are hidden unless added. An entry's attribution and licence are
registered in the Data attribution popover while it is in use.
Seeds: NASA EONET open events (GeoJSON, browser-direct), EIA US power plants
≥ 100 MW (ArcGIS, browser-direct, ranked by capacity), Smithsonian GVP Holocene
volcanoes (WFS GeoJSON, proxied — the upstream sends no CORS headers), NASA
Black Marble 2016 night lights and OpenSeaMap seamarks (tiles), and the NYC,
Lithuania and FL511 CCTV packs. Added from the Ask dataset registry (its live,
mappable sources that are not core layers): the WRI Global Power Plant Database
(provider-hosted CSV, the 10,000 largest of ~35,000 by capacity, so it reads
PARTIAL), NWS active alerts that carry their own polygon (zone-wide alerts have
no geometry and are dropped), NOAA CO-OPS tide stations (JSON records), and IMF
PortWatch chokepoints and port disruptions (ArcGIS, operator opt-in until the
reuse terms are confirmed, as Ask holds them in hosted mode). FEMA/ORNL USA
Structures (CC BY 4.0) is the first view-bounded entry; its service refuses a
query page smaller than its own 2,000 records, so the probe asks for a full
page. Addresscloud's EPC ratings for England and Wales (Source Cooperative,
read from `s3.us-west-2.amazonaws.com/us-west-2.opendata.source.coop`, 344
local-authority GeoParquet files, OGL v3.0) are the first partitioned
GeoParquet entry, coloured by band A–G with the official EPC colours. From an
open/freemium registry review (commercial sources excluded): GDACS disaster
alerts; the National Bridge Inventory (view-bounded, coloured Good/Fair/Poor);
EPA Superfund sites and Toxics Release Inventory facilities (view-bounded); and
tile overlays for Macrostrat geology, GBIF biodiversity density, USGS
topographic and hydrography maps, PDOK (Netherlands) and IGN (France) aerial
photos, BKG TopPlusOpen, GSI Japan map and photos, EMODnet bathymetry, SEDAC
GPW 2020 population density (legend from the GIBS colour map) and JRC surface
water occurrence. `npm run library:check` validates manifests,
the ledger (append-only with `--base-ref`, run in CI on pull requests) and the
generated DATA_SOURCES.md section; `npm run library:probe` hits every upstream
once; `npm run qa:data-library` is the browser gate (fixture-backed).
## Utilities panel — October 29, 2026 ## Utilities panel — October 29, 2026
UTILITIES is its own left-side panel, between Data Layers and Scenes, with UTILITIES is its own left-side panel, between Data Layers and Scenes, with
@@ -741,12 +942,6 @@ geographic seams and releases on disable; forecast animation is not implied.
NHC movement speed is converted from miles per hour to rounded knots by the NHC movement speed is converted from miles per hour to rounded knots by the
proxy; advisory wind intensity is already in knots and is passed through. proxy; advisory wind intensity is already in knots and is passed through.
Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source. Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source.
Explicitly selecting a storm publishes one shared `weather-cyclones` record:
advisory provenance, source position, the English classification name and,
while the proxy serves its cache, the cache fetch time. The automatic row
selection does not claim shared context; refreshes do not re-announce it.
Clear or disable reports `deliberate`; a storm leaving the feed reports
`evicted`, with no fallback.
Consult the linked official advisory for safety decisions. Consult the linked official advisory for safety decisions.
Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state.
@@ -1558,32 +1753,6 @@ owners. Tuner calculations have a pure entry, with existing layer exports
preserved. Destruction removes listeners and state subscriptions before ending preserved. Destruction removes listeners and state subscriptions before ending
tuning; a delayed Enable result cannot reveal or refocus removed controls. tuning; a delayed Enable result cannot reveal or refocus removed controls.
## Giroc / Webcam Romania CCTV source pack
`config/cctv_sources.romania.json` is an optional single-camera pack for Giroc,
Timiș, Romania. To use it, set `CCTV_SOURCES_FILE=config/cctv_sources.romania.json`
in the local `.env` and restart the server. Set `CCTV_FORCE_AUSTIN=1` as well to
retain the built-in live packs; despite its historical name, this flag applies
to all live packs. The default catalog is unchanged.
The camera uses Webcam Romania's public HLS stream and JPEG snapshot, fetched
at runtime without an API key. Its position, 45.70028947266781, 21.233675914004227,
was corrected by the contributor rather than copied from the directory's
inaccurate pin. Heading, pitch, field of view, mounting height and ground
elevation are uncalibrated priors; the camera retains the RAW PRIOR badge.
See `DATA_SOURCES.md` for attribution and provider terms. Webcam Romania's
terms grant no reuse licence for the video and name no camera operator; the
provider's sanctioned route is its iframe embed. Keep this pack to local use
and off hosted deployments unless the operator grants permission.
The existing HLS puller resolves same-origin relative master/media references
and serves local leased playlists and segments. It downloads complete segments
without forwarding a browser Range header. Its per-segment streamed-byte cap
is 8 MiB: Giroc's roughly ten-second 2702×1520 segments can exceed the former
4 MiB cap (one measured 6,437,120 bytes). Retained data remains capped at
24 MiB and 12 segments per session, with two sessions maximum. Redirect
refusal, origin pinning, timeouts and lease expiry are unchanged.
## NYC DOT TMC CCTV source pack ## NYC DOT TMC CCTV source pack
The CCTV catalog can add New York City traffic cameras from the NYC DOT The CCTV catalog can add New York City traffic cameras from the NYC DOT
@@ -1599,11 +1768,9 @@ from the payload. There is no heading in the feed, so gizmos use the shared
id-hash fallback at low confidence. id-hash fallback at low confidence.
Once enabled, the 250 cameras nearest the five borough cores load. Once enabled, the 250 cameras nearest the five borough cores load.
`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The default catalog limit is `CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The catalog-wide ceiling stays at 4,000 and is shared
7,350: 4,000 shared round-robin slots, plus room above them for the 1,120 round-robin: no region is dropped outright, but when the packs together exceed
Québec locations and 2,230 World image sources. Lower explicit the ceiling, adding a pack trims cameras from the largest packs.
`CCTV_MAX_SOURCES` values are honored; reservations only use the capacity
above the 4,000 shared slots.
## City of Toronto RESCU CCTV source pack ## City of Toronto RESCU CCTV source pack
@@ -1619,9 +1786,8 @@ cameras return a 200 "Image Unavailable" placeholder frame instead of an error.
By default all ~336 cameras load, nearest downtown first. By default all ~336 cameras load, nearest downtown first.
`CCTV_TORONTO_MAX_SOURCES` sets the pack cap (8–500) and `CCTV_TORONTO_MAX_SOURCES` sets the pack cap (8–500) and
`CCTV_TORONTO_ENABLED=0` turns the pack off. The pack shares the default `CCTV_TORONTO_ENABLED=0` turns the pack off. The pack shares the 4,000
7,350-source catalog limit with the other packs; Québec and the World image catalog-wide ceiling round-robin with the other packs.
pack have reserved slots and the remaining sources use round-robin allocation.
## Lithuania CCTV source pack ## Lithuania CCTV source pack
@@ -1641,8 +1807,24 @@ video; camera poses use the existing low-confidence heading prior.
## Taiwan CCTV source pack ## Taiwan CCTV source pack
The CCTV catalog includes Taiwan national freeway cameras from the Freeway Bureau, MOTC. The app first reads the daily MOTC 2.0 CCTV.xml table with a keyless HTTPS request, a 15-second deadline, a 6 MiB response cap, and the shared 15-minute cache. If that catalog is unreachable and no last-good source list exists, the loader uses the Highway Bureau’s public provincial-road CCTV XML feed at https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml. The fallback is capped by the same Taiwan source limit and hourly retry backoff; its MJPEG feeds are restricted to the eight cctv-ss01 through cctv-ss08.thb.gov.tw HTTPS servers. The frame proxy returns one bounded JPEG from each stream, not an unbounded video response. The Highway Bureau open-data page states XML downloads are free and asks users to attribute the source; the app credits Highway Bureau, MOTC. These are provincial-road cameras, not a replacement for the national freeway cameras. CCTV_TAIWAN_ENABLED=0 disables both feeds; CCTV_TAIWAN_MAX_SOURCES sets the shared cap (default 600, bounded to 1–2000), and CCTV_TAIWAN_URL overrides the Freeway Bureau table URL. The CCTV catalog includes Taiwan national freeway cameras from the Freeway
Gate: src/data/cctvTaiwan.test.mjs and src/data/cctvProxy.test.mjs. Bureau, MOTC. One keyless request reads the daily MOTC 2.0 `CCTV.xml` table
(~1,800 cameras); the request refuses redirects, has a 15-second deadline and a
6 MiB response cap, and uses the shared 15-minute cache and fair catalog
allocation. A failed download serves the last good table (up to a day old),
or an empty pack, and is not retried for an hour: the catalog host does not
answer from some networks outside Taiwan, and without the backoff every refresh
there would wait out the connect timeout. The table
publishes MJPEG stream URLs; each is mapped to the same camera server's
single-JPEG path for the same camera number, and only those exact HTTPS pairs
on the four `freeway.gov.tw` camera servers are registered with the frame
proxy. Camera keys come from the server and camera number, because published
CCTV IDs contain Chinese place names and a few repeat across cameras. Poses use
the low-confidence heading prior. `CCTV_TAIWAN_ENABLED=0` disables the pack;
`CCTV_TAIWAN_MAX_SOURCES` sets its cap (default 600, bounded to 1–2000), and
`CCTV_TAIWAN_URL` overrides the table URL.
Gate: `src/data/cctvTaiwan.test.mjs`.
## DriveBC CCTV source pack ## DriveBC CCTV source pack
The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the
@@ -4360,35 +4542,35 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
requests use Play; a qualified Play-shaped tool call is normalized to Select so requests use Play; a qualified Play-shaped tool call is normalized to Select so
its criteria cannot be silently ignored. its criteria cannot be silently ignored.
| Layer | Source | File | Proxy | Update Interval | | Layer | Source | File | Proxy | Update Interval |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s | | Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s | | Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) | | Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles | | Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | | Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog | | Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Iceland (Vegagerðin / IRCA) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static | | Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static | | Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static | | Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | | FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) | | Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | | Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) | | GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop | | S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) | | Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) | | Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) | | Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) | | Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) |
GNSS Integrity samples a 250 nm circle around the Earth point under the screen GNSS Integrity samples a 250 nm circle around the Earth point under the screen
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap; centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
@@ -4442,10 +4624,8 @@ provider draws in one colour (`PROVIDER_COLORS` in
sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers' switches by id, sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers' switches by id,
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google
Street View, KartaView and Panoramax are registered. Without Street View, KartaView and Panoramax are registered. Without
`MAPILLARY_CLIENT_TOKEN` (and no Graph proxy) Street Level starts on the first `MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
keyless provider (KartaView), including a restored "Mapillary on" unless the link disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
switches the keyless providers off; filter-only updates never change the source.
With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
requests for a tile. requests for a tile.
@@ -4459,7 +4639,7 @@ the camera, nearest first, at most 9 (the halves of a view across
the date line count a shared tile once). Lines are draped the date line count a shared tile once). Lines are draped
`GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain `GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain
and Google 3D tiles alike; cones and the photo marker are ground-clamped and Google 3D tiles alike; cones and the photo marker are ground-clamped
billboards with a horizon cull. When Mapillary is lit without `MAPILLARY_CLIENT_TOKEN` the panel billboards with a horizon cull. Without `MAPILLARY_CLIENT_TOKEN` the panel
reads KEY REQUIRED, says which key to add and disables the filters; a rejected reads KEY REQUIRED, says which key to add and disables the filters; a rejected
key reads KEY REJECTED and stops tile requests until the layer goes off; a 429 key reads KEY REJECTED and stops tile requests until the layer goes off; a 429
pauses them for its Retry-After. pauses them for its Retry-After.
@@ -5349,7 +5529,7 @@ silently demoting every later lookup for the session.
- Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art. - Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art.
- The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset. - The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset.
- Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`. - Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`.
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless navigation took it, and a toast says the mission index could not load. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. Exiting cancels the layer's own camera flights but never one navigation started. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. - Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
@@ -5370,7 +5550,7 @@ silently demoting every later lookup for the session.
### Motion & Symbology Correctness (June 10, 2026) ### Motion & Symbology Correctness (June 10, 2026)
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.” - **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The whole fleet dead-reckons at ~12Hz (1m² write gating); aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
- **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder. - **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder.
- **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them. - **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them.
- **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode. - **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode.
@@ -5435,10 +5615,8 @@ silently demoting every later lookup for the session.
kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list
covering the whole country, where one _preset_ (a station's fixed view) is one camera — 806 covering the whole country, where one _preset_ (a station's fixed view) is one camera — 806
GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors
on the main road spine. `CCTV_MAX_SOURCES` defaults to 7,350: 4,000 shared on the main road spine. `CCTV_MAX_SOURCES` is a 4,000 catalog-wide ceiling shared round-robin
round-robin slots plus 3,350 reserved Québec and World image slots. across packs, so a lower global cap thins every region instead of starving the last pack.
Reservations only use capacity above the 4,000 shared slots, so at 4,000 or
less every pack, Québec and World included, shares the cap round-robin.
~1,100 cameras total, all RAW PRIOR poses, stills-first. The catalog lives for 15 minutes; once ~1,100 cameras total, all RAW PRIOR poses, stills-first. The catalog lives for 15 minutes; once
it expires, `/api/cctv` keeps answering from it while one background refresh reloads every it expires, `/api/cctv` keeps answering from it while one background refresh reloads every
pack, so only the very first load waits for the slowest pack (a live first load took about pack, so only the very first load waits for the slowest pack (a live first load took about
@@ -6030,7 +6208,6 @@ request viewbox and continues searching without that geographic preference.
- **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked. - **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked.
- **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere. - **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere.
- **Event age filter.** One **Event age** switch in the S2 GhostMaps row applies to every selected product: 1, 3, 7 (default), 14 or 30 days, or **All** (every marker). The choice is saved separately from product selection in `localStorage` (`gev.ghostmaps.ageDays`); missing, invalid or blocked storage falls back to seven days. Age comes only from the placemark's `Date of Event` (or `EventDate`/`Date`), never from report, prose or KMZ edition dates. Undated, unparseable and future dates stay visible. ArcGIS `M/D/YYYY` dates with optional AM/PM times and ISO dates are read as UTC when no zone is given; a date without a time covers its whole UTC day, so **1d** still shows yesterday's reports. Events age out every minute while the layer is on. Narrowing the window only removes markers; widening restores them from the cached KMZ. An open card stays while its marker is shown.
- **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub. - **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub.
- It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min. - It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min.
- Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit. - Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit.
@@ -6452,26 +6629,7 @@ easier to meet (detection is now on more often), but does not create it.
- Road source (`src/layers/traffic/roadModes.js`): the Street Traffic row has - Road source (`src/layers/traffic/roadModes.js`): the Street Traffic row has
TomTom / OSM / Hybrid chips (`roadMode` param, stored in the existing layer TomTom / OSM / Hybrid chips (`roadMode` param, stored in the existing layer
state `lo` field, no new share token); `?trafficRoads=tomtom|osm|hybrid` state `lo` field, no new share token); `?trafficRoads=tomtom|osm|hybrid`
overrides restored state until the user picks a chip. A host may offer overrides restored state until the user picks a chip. With no stored choice
fewer modes with `createTrafficSource({ roadModes })` (a non-empty subset,
default all three): only offered modes get chips (none for a single mode),
and other choices from chips, restores, share links or the query are
ignored. Without `osm` the layer never simulates: keyless or with the
status probe unreachable, loads fail with "TomTom roads need a TomTom key"
or "TomTom roads unavailable while the traffic service is unreachable" and
no roads are drawn.
- Row toggles (live mode only): after the road-source chips, Lines draws
every road with flow as a steady draped line in its bucket color (green
free, amber slow, red jam; jam first, then slow, then free, longest first,
capped at 1,500; on 3D tiles under photoreal, on terrain or tiles
otherwise), and Dots shows or hides the moving dots (hidden dots are kept,
are not offered to detection overlays, and hold no render frames).
Without live flow only a hidden Dots toggle is offered, so dots hidden by a
live choice, a restore or a share link can always be shown again.
Defaults: Lines off, Dots on. Both are `lines` / `dots` params stored in the
layer state `lo` field (`l`, `d`). Toggling either restyles in place, with
no refetch. The owner `jamViz` heat-line prototype is unchanged when Lines
is off. With no stored choice
the default is Hybrid with a TomTom key and OSM without one; share links the default is Hybrid with a TomTom key and OSM without one; share links
without a road-source choice (including older ones) likewise use the without a road-source choice (including older ones) likewise use the
installation's default (Hybrid when keyed, OpenStreetMap when keyless). Without a key installation's default (Hybrid when keyed, OpenStreetMap when keyless). Without a key
@@ -7115,79 +7273,3 @@ the container healthy. Checks time out after 10 seconds, with a 30-second
startup grace period and three consecutive failures before reporting unhealthy. startup grace period and three consecutive failures before reporting unhealthy.
This reports app HTTP availability, not individual provider health, and does not This reports app HTTP availability, not individual provider health, and does not
configure automatic restarts. configure automatic restarts.
## Québec CCTV integration
The regular CCTV source loader reads `public/quebec-cameras.json` by default
when `CCTV_QUEBEC_ENABLED` is not `0`. The pack contains 1,120 records: 680
MTQ highway cameras, 426 Montréal OSM camera locations, and 14 scenic webcams.
The default catalog limit is 7,350: it reserves all 1,120 Québec locations and
2,230 World image feeds, and keeps 4,000 slots for the existing round-robin
camera packs. Reservations only use capacity above those 4,000 slots: a lower
explicit `CCTV_MAX_SOURCES` thins every pack round-robin, Québec and World
included, instead of taking the shared slots.
The Québec 511 video is opt-in (`CCTV_QUEBEC_VIDEO_ENABLED=1`) because Québec
511's terms for embedding its clips on another site are unconfirmed; the camera
locations themselves are CC BY 4.0. With the flag unset, MTQ cameras show
their location, credit and a link to the Québec 511 viewer page. With it set,
selecting an MTQ camera opens a floating panel with a Play button and a notice
that Québec 511 sees the viewer's IP address; the browser contacts Québec 511
only after Play, never on selection alone (layer enable, auto-hop, a scene or
voice), and a different camera starts unplayed. After Play, the panel plays
the official MP4 in a native browser player and requests a second video for the
paused map preview. The official feed is a five-second clip refreshed by
Québec 511 every 2–4 minutes, not continuous live video; the browser player
reloads every two minutes while the tab is visible. The
map preview is a DOM overlay attached to the camera's coordinates and scales
down with camera distance. Neither video is read into a canvas or Cesium texture:
Québec 511 does not grant localhost pixel access, and its MP4 is not available
to the app's server proxy. Montréal and scenic entries are regular CCTV camera
locations with per-camera source-page links, but no direct frame URLs; those
show a location label and open the official/operator page. MTQ positions carry
CC BY 4.0 attribution, Montréal positions are attributed to OpenStreetMap
contributors under ODbL, and scenic rows retain their position accuracy and
provenance. Québec cameras load through the normal CCTV layer.
## City of Ottawa CCTV source pack
The CCTV catalog can add City of Ottawa traffic cameras, including the MTO
cameras the city carries. Camera images come from the city's Traffic Camera
Images API (`traffic.ottawa.ca/opendata/camera`), which needs a certificate
from the city's registration form and lets one certificate and user id fetch a
camera at most once a minute. Without `OTTAWA_CAMERA_CERTIFICATE` the pack is
skipped with no request and one log line. The camera list is the documented
keyless `traffic.ottawa.ca/map/service/camera` JSON. Catalog URLs carry no
key: the frame proxy adds the certificate and the fixed user id `gev` at fetch
time, only for a numeric camera number inside the pinned `/opendata/` path,
and serves each camera from one shared server-side frame per minute. Inside
that minute a camera with no cached frame gets the placeholder, never the
Street View fallback. The feed publishes no bearing, so headings use the
id-hash fallback at low confidence. `CCTV_OTTAWA_MAX_SOURCES` caps the pack
(default 250, nearest downtown) and `CCTV_OTTAWA_ENABLED=0` turns it off.
## World camera image feeds
The default `world-cameras` pack reads only the checked-in, curated
`config/cctv_sources.world-image-cameras.json` file. It adds 1,176 Oregon
TripCheck and 1,054 MLIT river cameras. Duplicate feeds are excluded because
DriveBC, Ottawa, Québec and Iceland (Vegagerðin) already have app packs. NZTA's
249 rows are excluded because they were collected from a Journey Planner web
cache whose current terms prohibit automated scraping; an authorized API feed
or permission is required before adding them. The wider discovery export is
not shipped with the app.
The app validates and pins each still-image URL to its provider directory
before the server proxy uses it. Oregon frames refresh no faster
than every five minutes; MLIT river frames refresh every ten minutes to reduce
requests. Images are not persisted; the proxy reuses frames in a bounded 32 MB
in-memory cache until their provider cadence. Set
`CCTV_WORLD_CAMERAS_ENABLED=0` to disable the pack.
Only the curated image-backed rows are bundled. Camera images are fetched from
their registered providers at request time and are not included in this data
file. The additive `/api/cctv/sources` metadata includes provider source links,
browser-only media links, refresh cadence, and location provenance where
available. When MLIT serves its recognized adjustment image, `/api/cctv/frame`
returns a God’s Eye maintenance card and `/api/cctv/health` reports the camera
as degraded with the provider-adjustment reason.
+183
View File
@@ -0,0 +1,183 @@
# Data Library
DATA LAYERS is the curated set of core layers. The Data Library holds
everything else: regional sources, niche themes and community contributions.
A viewer opens it from **DATA LAYERS ▸ Browse data library**, adds what they
want, and the entry appears in the panel under **Library**. Nothing in the
library is on until someone adds it.
Runtime behaviour is described in [CURRENT-STATE.md](CURRENT-STATE.md#data-library--october-21-2026).
This page is the authoring guide.
## The one rule: live sources only
A library entry points at data its provider hosts. That can be a query API, a
static file at a stable URL the provider controls, or a tile endpoint. The
data is never copied into this repository. If the upstream goes away, the
entry goes stale; this project never becomes the host of record. That is what
lets every self-hosted instance serve the whole library with no data of its
own.
It also keeps the existing rule in [CONTRIBUTING.md](../CONTRIBUTING.md): do
not add data you have no right to redistribute. A live source still needs a
licence that allows this use, and the manifest must say what it is.
## Kinds
| Kind | What it is | Renders through |
| --- | --- | --- |
| `dataset` | Vector features: `geojson`, `geojsonl`, `csv` (lat/lon columns), `json` (a record list with lat/lon fields), `arcgis` (a FeatureServer or MapServer layer, paged) or `geoparquet` (one file, or a partitioned set on S3) | The GeoJSON layer Datacenters and Dams use; view-bounded and GeoParquet sets draw as ground-clamped dots and polygons |
| `tiles` | Raster tiles: `xyz` with a `{z}/{x}/{y}` template (`{reverseY}` for TMS rows), or `bbox` with a `{bbox-epsg-3857}` template (WMS GetMap, ArcGIS `export`/`exportImage`) | The active map's imagery host (globe or Google 3D tiles) |
| `pack` | A source pack an existing host layer already loads (today: CCTV) | The host layer, switched on by its own environment variable |
A `dataset` or `tiles` entry needs no JavaScript. A `pack` documents a host
layer's opt-in source so viewers can see it and operators know the switch; the
host layer owns its code. Provider entries (code implementing a family
contract, such as a Street Level provider) are not supported yet and are
refused by the validator.
## Add an entry
1. Create `src/library/entries/<id>.json`. The id is 3–48 lowercase letters,
digits or dashes and is permanent.
2. Add one import line for it in `src/library/catalog.js` (keep the list in
file-name order).
3. For a `dataset` or `tiles` entry, run `npm run library:next -- <id>` and
append `["<id>", "<token>"]` to `src/library/ledger.json`. Tokens are
permanent: never change or reuse a row, even when an entry is removed.
Packs take no token.
4. Run `npm run library:docs` to regenerate the DATA_SOURCES.md table.
5. Run `npm run library:check -- --base-ref origin/main`,
`npm run library:probe`, `npm test` and `npm run qa:data-library`.
6. Open a pull request with a `changelog.d/` fragment.
Start new entries at `"status": "community"`. Maintainers move an entry to
`verified` after reviewing its licence and seeing it stay healthy; `stale` and
`archived` mark entries whose upstream has stopped answering. Archived entries
are hidden from Browse unless a viewer already added them.
## Manifest reference
```json
{
"id": "eia-power-plants",
"kind": "dataset",
"family": "infrastructure",
"title": "US power plants, 100 MW and up (EIA)",
"provider": "EIA",
"description": "Operating US power plants with at least 100 MW of capacity.",
"icon": "ϟ",
"maintainer": "github:your-handle",
"coverage": { "countries": ["US"], "bbox": [-180, 13, -64, 72] },
"source": {
"format": "arcgis",
"url": "https://services2.arcgis.com/…/Power_Plants_in_the_US/FeatureServer/0",
"query": { "where": "Total_MW >= 100", "outFields": "Plant_Name,PrimSource,Total_MW" },
"fetch": "browser-direct",
"cacheSeconds": 86400,
"maxFeatures": 4000,
"maxPages": 3
},
"display": {
"color": "#ffc23d",
"label": "Plant_Name",
"fields": ["PrimSource", "Total_MW"],
"rank": { "field": "Total_MW", "order": "desc" }
},
"licence": { "name": "EIA data: public domain", "url": "https://www.eia.gov/about/copyrights_reuse.php" },
"attribution": { "text": "U.S. Energy Information Administration", "url": "https://atlas.eia.gov/" },
"status": "community",
"reviewed": "2026-10-21"
}
```
Unknown fields are errors, so a typo cannot silently drop a cap or a licence.
| Field | Required | Notes |
| --- | --- | --- |
| `id`, `kind`, `family`, `title` (≤ 60), `provider` (≤ 32), `description` (≤ 240), `maintainer`, `status` | yes | `family` is one of movement, cameras, infrastructure, events, weather, environment, imagery. `provider` is the short name the panel row shows. |
| `coverage` | yes | `global: true`, ISO 3166-1 alpha-2 `countries`, and/or a `bbox` `[west, south, east, north]`. |
| `source.url` | yes | https, a real hostname, no credentials or fragment. |
| `source.format` | yes | Datasets: `geojson`, `geojsonl`, `csv`, `json`, `arcgis`, `geoparquet`. Tiles: `xyz` (`{z}`, `{x}` and `{y}` or `{reverseY}`) or `bbox` (one `{bbox-epsg-3857}`, filled with the tile's Web Mercator bounds in metres, as MapLibre does). Packs: `host`. |
| `source.records` | no | JSON only: the property path to the record list, such as `stations` for `{ "stations": [ … ] }`. Omit when the response is the list itself. |
| `source.fetch` | datasets, tiles | `browser-direct` when the upstream sends CORS headers and needs no key; otherwise `proxied`. |
| `source.query` | no | Query parameters added to the URL. For `arcgis`, `where` and `outFields`; geometry is always WGS84 GeoJSON. |
| `source.columns` | no | CSV or JSON: `{ "lat": "…", "lon": "…" }` when the fields are not lat/latitude/y and lon/lng/longitude/x. |
| `source.cacheSeconds` | no | 60–86,400, default 3,600. How long the proxy keeps a response. |
| `source.maxBytes` | no | Body cap, default 16 MiB, at most 32 MiB. |
| `source.maxFeatures` | no | Feature cap, default 10,000, at most 20,000. A capped dataset reads PARTIAL. |
| `source.maxPages` | no | ArcGIS pages to follow, default 5, at most 10. |
| `source.view` | no | `{ "maxAltitudeM": 300–50000, "sample": [w, s, e, n] }`. Makes a huge ArcGIS layer view-bounded: below that camera height it loads what is in view, one page at a time; above it the row asks the viewer to zoom in. Needs `arcgis` or `geoparquet`, and `browser-direct`. `sample` is where Zoom to layer lands and what `library:probe` queries. |
| `source.partitions` | no | GeoParquet only: `{ "listUrl": "<S3 ListObjectsV2 URL with prefix>", "keyBase": "<URL keys are relative to>" }`, both on the source host. Needs `source.view`. The library indexes each file by its footer bbox and reads only the files in view. |
| `source.minZoom`, `source.maxZoom` | no | Tiles only, 0–22. |
| `source.encoding` | no | Tiles (`xyz`): `terrarium` when the pixels are elevation as terrain-RGB (height = R × 256 + G + B / 256 − 32768 m). The browser draws them as shaded relief: one orange ramp in six elevation classes (legend in metres), lit from the north-west; sea (exactly 0 m) is transparent. |
| `source.tileSize` | no | Tiles: 256 (default) or 512 px. |
| `source.bounded` | no | Tiles with `coverage.bbox`: `true` requests tiles inside the bbox only. Required for aerial imagery, whose servers paint a fill (white, a flat ocean, a coarse world mosaic) everywhere they have no photos. A 4xx tile answer is treated as "no imagery here", never retried or counted as an error. |
| `source.key` | no | `{ "id": "<key registry id>", "in": "query" \| "header", "name": "…" }`. The id must exist in `src/keySetupCore.mjs`. A keyed entry must be `proxied`, so the key never reaches the browser. |
| `display.color`, `display.label`, `display.fields` (≤ 4, two shown) | no | Datasets only, except `opacity`. |
| `display.rank` | no | `{ "field": "…", "order": "desc" \| "asc" }`. Keeps the most important features when capped and lets the map prefer them at global zoom. Numbers or ISO dates. Without it, a zoomed-out view shows whichever features come first. |
| `display.colorBy` | no | View-bounded datasets: `{ "field": "…", "colors": { "<value>": "#rrggbb" }, "labels": { "<value>": "…" } }`, up to 12 values. Colours each feature by value; the row legend lists each label with the count in view. |
| `display.legend` | no | `[{ "label": "…", "color": "#rrggbb" }]`, up to 12: a static legend, such as a tile layer's colour scale. Take the colours from the provider's own legend or colour map. |
| `display.outline` | no | Tiles: `countries` outlines every part of each covered country, `coverage` only the parts inside `coverage.bbox`, `bbox` the bbox rectangle itself (a state, county or city inside a country). Use it for aerial imagery so its edge shows. |
| `display.aerial` | no | Tiles: `true` for aerial or satellite photography. It drapes above every other imagery layer, including core overlays, and stays there when another layer is switched on. Pair it with `source.bounded`. |
| `display.noDataColor` | no | Tiles: `#rrggbb` of the provider's no-imagery fill, drawn transparent (within a small tolerance for JPEG noise along its edge). Measure it from a tile outside the coverage; do not guess. Globe maps only: Google 3D tiles ignore it. |
| `display.opacity` | no | Tiles: (0, 1]. |
| `imagery` | no | Tiles: what a photo layer is. `type` (`ortho`, `satellite-mosaic`, `oblique`, `historic`), `resolution` as the provider publishes it ("10 cm (plains) / 25 cm (Alps)"), `resolutionM` (the finest, in metres), `trueOrtho`, and optional `vintage` ("2021–present"), `updateCycle`, `orthoQuality` and `registry` (an https link to where the metadata came from). Browse shows it as a spec list on the card. |
| `pack` | packs | `{ "host": "cctv", "env": "CCTV_EXAMPLE_ENABLED" }`. Packs are always `licence.operatorOptIn: true`. |
| `licence` | yes | `name`, an https `url`, and `operatorOptIn: true` when the terms need the operator's own agreement or consent. |
| `attribution` | yes | `text` and an optional https `url`; shown in the Data attribution popover while the entry is in use. |
| `regionsServed` | no | ISO codes, when the upstream only answers from some countries. |
| `reviewed` | no | `YYYY-MM-DD` of the last licence and endpoint review. |
## Aerial imagery from the Open Imagery Register
The aerial photo entries came from the [Open Imagery Register](https://imagery-registry.netlify.app/)
on 2026-10-23: every register source with a map preview, less the ones that
fail the library's rules. Each entry keeps the register's resolution, product
type, true-ortho flag, years flown and refresh cycle in `imagery`. Before an
entry was written, a tile inside its coverage was fetched in a browser from
the app's origin: it had to be a real photo (not a blank fill), and the
provider had to send CORS headers (or the entry would be `proxied`). The
fill a provider paints at its coverage corners became `display.noDataColor`.
Left out, with the reason:
| Register source | Why |
| --- | --- |
| NASA GIBS true colour | Already in core (Recent imagery). |
| Sentinel-2 cloudless (EOX) | CC BY-NC-SA: non-commercial. |
| SWISSIMAGE Bern and Marion County PMTiles | Extracts hosted by the register, not the provider. |
| Cabarrus County NC, Hawaii, Nashville, Sioux Falls | Credit a commercial imagery vendor (EagleView, Maxar WorldView, Pictometry, Nearmap). |
| DVRPC, Missouri, Washington | The preview serves a blank white tile across the coverage centre. |
| Sarasota County FL | The service now requires a token. |
Entries whose register licence reads "see publisher terms" or "verify portal
terms" are `licence.operatorOptIn: true`: an operator reads the publisher's
terms and lists the id in `GEV_LIBRARY_ENABLE`. Re-check an entry's terms and
drop the opt-in when its publisher states an open licence.
## Choosing a fetch mode
Use `browser-direct` when the upstream answers a browser on another origin
(`Access-Control-Allow-Origin`) and needs no key. Tile servers must send CORS
headers for WebGL to use them; without them, use `proxied`.
Use `proxied` otherwise. The proxy is generic: there is no per-entry server
code. It reaches only the manifest's host, refuses redirects, caps bodies,
caches per `cacheSeconds` and never returns upstream error text. The browser
names an entry id, never a URL, so the proxy cannot be pointed anywhere else.
## Operator switches
| Variable | Effect |
| --- | --- |
| `GEV_LIBRARY_ENABLE` | Comma-separated ids of `operatorOptIn` datasets and tiles to serve. |
| `GEV_LIBRARY_DISABLE` | Comma-separated ids to remove from this instance. |
| A pack's own variable | For example `CCTV_NYC_ENABLED=1`; Browse then shows the pack as switched on. |
## When an entry belongs in core instead
A library entry can be proposed for DATA LAYERS when it has global or
near-global coverage, is keyless or uses a key the registry already has, has
stayed `verified` with no stale period for about six months, and a core
maintainer will own it. Promotion and demotion are maintainer decisions.
+1 -1
View File
@@ -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,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,delete,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,pest_control,pets,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop,zoom_in_map" 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>
+14
View File
@@ -15,7 +15,9 @@
"@meri-imperiumi/eccodes-wasm": "^2.48.2", "@meri-imperiumi/eccodes-wasm": "^2.48.2",
"cesium": "^1.124.0", "cesium": "^1.124.0",
"egm96-universal": "^1.1.1", "egm96-universal": "^1.1.1",
"fzstd": "0.1.1",
"hls.js": "^1.7.3", "hls.js": "^1.7.3",
"hyparquet": "1.31.2",
"mapillary-js": "^4.1.2", "mapillary-js": "^4.1.2",
"mgrs": "^2.1.0", "mgrs": "^2.1.0",
"pbf": "^5.1.2", "pbf": "^5.1.2",
@@ -2452,6 +2454,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/fzstd": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/fzstd/-/fzstd-0.1.1.tgz",
"integrity": "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==",
"license": "MIT"
},
"node_modules/get-caller-file": { "node_modules/get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -2562,6 +2570,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/hyparquet": {
"version": "1.31.2",
"resolved": "https://registry.npmjs.org/hyparquet/-/hyparquet-1.31.2.tgz",
"integrity": "sha512-dpVTCGkPPIdfDExa7YZGaJpuBTmLFDKGeZ6meHUj1mz/Q3qmaQHGLLqCyPBkQjfxrYNb08YTax682qeGO+zkVA==",
"license": "MIT"
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+7
View File
@@ -38,6 +38,10 @@
"i18n": "node scripts/i18n.mjs", "i18n": "node scripts/i18n.mjs",
"layer-token:next": "node scripts/next-layer-state-token.mjs", "layer-token:next": "node scripts/next-layer-state-token.mjs",
"layer-token:check": "node scripts/check-layer-state-tokens.mjs", "layer-token:check": "node scripts/check-layer-state-tokens.mjs",
"library:check": "node scripts/library.mjs check",
"library:next": "node scripts/library.mjs next",
"library:docs": "node scripts/library.mjs docs",
"library:probe": "node scripts/library.mjs probe",
"changelog": "node scripts/assemble-changelog.mjs --write", "changelog": "node scripts/assemble-changelog.mjs --write",
"changelog:check": "node scripts/assemble-changelog.mjs --check", "changelog:check": "node scripts/assemble-changelog.mjs --check",
"dev": "vite", "dev": "vite",
@@ -54,6 +58,7 @@
"qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures", "qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures",
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs", "check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
"qa:transit": "node scripts/qa-transit.mjs", "qa:transit": "node scripts/qa-transit.mjs",
"qa:data-library": "node scripts/qa-data-library.mjs",
"mcp": "node server/mcp/stdio.js" "mcp": "node server/mcp/stdio.js"
}, },
"dependencies": { "dependencies": {
@@ -63,7 +68,9 @@
"@meri-imperiumi/eccodes-wasm": "^2.48.2", "@meri-imperiumi/eccodes-wasm": "^2.48.2",
"cesium": "^1.124.0", "cesium": "^1.124.0",
"egm96-universal": "^1.1.1", "egm96-universal": "^1.1.1",
"fzstd": "0.1.1",
"hls.js": "^1.7.3", "hls.js": "^1.7.3",
"hyparquet": "1.31.2",
"mapillary-js": "^4.1.2", "mapillary-js": "^4.1.2",
"mgrs": "^2.1.0", "mgrs": "^2.1.0",
"pbf": "^5.1.2", "pbf": "^5.1.2",
-1
View File
@@ -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
+1 -1
View File
@@ -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
-3
View File
@@ -347,7 +347,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",
@@ -475,7 +474,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,7 +581,6 @@
"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/cctvHouston.test.mjs",
"src/data/cctvNswSource.test.mjs", "src/data/cctvNswSource.test.mjs",
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env node
/**
* Data Library maintenance. See docs/DATA-LIBRARY.md.
*
* npm run library:check [-- --base-ref origin/main]
* Validate every manifest, the share-token ledger and the generated
* DATA_SOURCES.md section. With --base-ref, also prove the ledger only
* appended rows since that ref (published tokens never change).
* npm run library:next -- <entry-id>
* Print the next free share token for a new switchable entry.
* npm run library:docs
* Rewrite the generated Data Library section of DATA_SOURCES.md.
* npm run library:probe [-- --json] [-- --strict]
* Hit every dataset and tile upstream once (bounded) and report
* status, latency and feature count. --strict exits 1 on a failure.
*/
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { LIBRARY_ENTRIES, LIBRARY_LEDGER } from '../src/library/catalog.js';
import { libraryTileUrl } from '../src/library/manifest.js';
import {
arcgisPageUrl,
datasetRequestUrl,
parseLibraryBody,
} from '../src/library/formats.js';
import {
nextLibraryToken,
readLibraryLedger,
} from '../src/library/selection.js';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const DATA_SOURCES = path.join(root, 'DATA_SOURCES.md');
const START = '<!-- data-library:start (generated by npm run library:docs) -->';
const END = '<!-- data-library:end -->';
const LEDGER_PATH = 'src/library/ledger.json';
const cell = (text) => String(text).replace(/\|/g, '\\|').replace(/\s+/g, ' ');
/** The generated DATA_SOURCES.md section, one row per entry. */
export function libraryDocsSection(entries = LIBRARY_ENTRIES) {
const rows = [...entries]
.sort((a, b) => a.id.localeCompare(b.id))
.map((entry) => {
const host = (() => {
try {
return new URL(entry.source.url.replace(/\{[xyz]\}/g, '0')).host;
} catch {
return '';
}
})();
const how =
entry.kind === 'pack'
? `CCTV pack; operator sets \`${entry.pack.env}=1\``
: `${entry.kind}, ${entry.source.format}, ${entry.source.fetch}` +
(entry.licence.operatorOptIn
? `; operator adds \`${entry.id}\` to \`GEV_LIBRARY_ENABLE\``
: '') +
(entry.source.key
? `; needs the \`${entry.source.key.id}\` key`
: '');
return `| **${cell(entry.title)}** (\`${entry.id}\`, \`${host}\`) | ${cell(how)} | [${cell(entry.licence.name)}](${entry.licence.url}) | "${cell(entry.attribution.text)}" |`;
});
return [
START,
'',
'## Data Library entries (fetched at runtime — not stored in this repo)',
'',
'Each Data Library entry is a manifest in `src/library/entries/` pointing at a live upstream its provider hosts; see [docs/DATA-LIBRARY.md](docs/DATA-LIBRARY.md). An entry appears on the map only after a viewer adds it from **DATA LAYERS ▸ Browse data library**, and its attribution is registered in the Data attribution popover while it is in use. This table is generated from the manifests: edit the manifest, then run `npm run library:docs`.',
'',
'| Entry | How it is served | Licence / terms | Attribution |',
'| --- | --- | --- | --- |',
...rows,
'',
END,
].join('\n');
}
function replaceSection(text, section) {
const start = text.indexOf(START);
const end = text.indexOf(END);
if (start < 0 || end < start) {
const anchor = text.indexOf('\n## Bundled snapshots');
if (anchor < 0)
throw new Error('DATA_SOURCES.md has no Bundled snapshots heading');
return `${text.slice(0, anchor)}\n${section}\n${text.slice(anchor)}`;
}
return text.slice(0, start) + section + text.slice(end + END.length);
}
function check(baseRef) {
const errors = [];
const ledger = readLibraryLedger(LIBRARY_LEDGER);
errors.push(...ledger.errors.map((error) => `ledger ${error}`));
for (const entry of LIBRARY_ENTRIES) {
const switchable = entry.kind !== 'pack';
if (switchable && !ledger.tokenById.has(entry.id))
errors.push(
`${entry.id}: no share token (npm run library:next -- ${entry.id})`,
);
if (!switchable && ledger.tokenById.has(entry.id))
errors.push(
`${entry.id}: packs are switched by their host and take no token`,
);
}
if (baseRef) {
let published = [];
try {
published = JSON.parse(
execFileSync('git', ['show', `${baseRef}:${LEDGER_PATH}`], {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}),
);
} catch {
published = [];
}
published.forEach(([id, token], index) => {
const row = LIBRARY_LEDGER[index];
if (!row || row[0] !== id || row[1] !== token)
errors.push(
`ledger row ${index} (${id} → ${token}) was published on ${baseRef} and must not change`,
);
});
LIBRARY_LEDGER.slice(published.length).forEach(([id, token], offset) => {
const expected = nextLibraryToken(
LIBRARY_LEDGER.slice(0, published.length + offset),
);
if (token !== expected)
errors.push(
`ledger row for ${id} should take ${expected}, not ${token}`,
);
});
}
const text = readFileSync(DATA_SOURCES, 'utf8');
if (!text.includes(libraryDocsSection()))
errors.push(
'DATA_SOURCES.md Data Library section is out of date (npm run library:docs)',
);
if (errors.length) {
for (const error of errors) console.error(`✗ ${error}`);
process.exit(1);
}
console.log(
`Data Library valid: ${LIBRARY_ENTRIES.length} entries, ${LIBRARY_LEDGER.length} share tokens${baseRef ? `, ledger append-only against ${baseRef}` : ''}.`,
);
}
/** Points inside a coverage bbox to try, centre first, as [fx, fy] shares. */
const PROBE_POINTS = [
[0.5, 0.5],
[0.4, 0.45],
[0.6, 0.55],
[0.25, 0.25],
[0.75, 0.75],
];
/**
* A Web Mercator tile to probe. Regional entries try points inside their
* coverage bbox (a regional server answers 404 or a blank fill outside its
* photos, and a bbox centre can be sea); global entries probe a mid-world
* tile.
*/
function probeTile(entry, z, [fx, fy] = PROBE_POINTS[0]) {
const n = 2 ** z;
const bbox = entry.coverage?.bbox;
if (!bbox) return { x: Math.floor(n / 2), y: Math.floor(n / 3) };
const lon = bbox[0] + (bbox[2] - bbox[0]) * fx;
const lat = Math.max(-85, Math.min(85, bbox[1] + (bbox[3] - bbox[1]) * fy));
const rad = (lat * Math.PI) / 180;
const clamp = (value) => Math.min(n - 1, Math.max(0, Math.floor(value)));
return {
x: clamp(((lon + 180) / 360) * n),
y: clamp(
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n,
),
};
}
async function probeEntry(entry) {
const started = Date.now();
const signal = AbortSignal.timeout(30_000);
try {
if (entry.kind === 'tiles') {
// Regional imagery is probed at z13, the Open Imagery Register's
// validation zoom (some caches start there); global layers at their
// coarsest level.
const minZoom = entry.source.minZoom ?? 0;
const z = entry.coverage.bbox
? Math.min(entry.source.maxZoom ?? 18, Math.max(minZoom, 13))
: minZoom;
const points = entry.coverage.bbox ? PROBE_POINTS : [PROBE_POINTS[0]];
let status = null;
let lastError = null;
for (const point of points) {
const { x, y } = probeTile(entry, z, point);
const url = libraryTileUrl(entry.source.url, z, x, y);
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(20_000),
redirect: 'error',
});
await response.arrayBuffer();
status = response.status;
if (
response.ok &&
/^image\//.test(response.headers.get('content-type') || '')
)
return { id: entry.id, ok: true, status, ms: Date.now() - started };
} catch (error) {
lastError = error;
}
}
if (status === null && lastError) throw lastError;
return { id: entry.id, ok: false, status, ms: Date.now() - started };
}
if (entry.source.format === 'geoparquet') {
// List the partitions and read one footer by range request: proof the
// bucket answers and the files are GeoParquet, without reading data.
const { listPartitions, rangeBuffer, geoParquetInfo } =
await import('../src/library/parquet.js');
const { parquetMetadataAsync } = await import('hyparquet');
const fetchImpl = (target, options = {}) =>
fetch(target, { ...options, signal });
const objects = entry.source.partitions
? await listPartitions(entry, { fetchImpl, signal })
: [{ key: entry.source.url, size: null }];
const first = objects[0];
const url = entry.source.partitions
? new URL(first.key, entry.source.partitions.keyBase).href
: entry.source.url;
const size =
first.size ??
Number(
(await fetch(url, { method: 'HEAD', signal })).headers.get(
'content-length',
),
);
const metadata = await parquetMetadataAsync(
rangeBuffer(url, size, { fetchImpl, signal }),
{ initialFetchSize: 16 * 1024 },
);
return {
id: entry.id,
ok: Boolean(geoParquetInfo(metadata).column),
status: 200,
ms: Date.now() - started,
features: Number(metadata.num_rows),
partitions: objects.length,
};
}
const url =
entry.source.format === 'arcgis'
? arcgisPageUrl(
entry.source.url,
entry.source.query,
0,
// Some huge view services refuse a page smaller than their own.
entry.source.view ? 2000 : 100,
// A view-bounded layer is probed over its sample area.
entry.source.view?.sample ?? null,
)
: datasetRequestUrl(entry.source);
const response = await fetch(url, { signal, redirect: 'error' });
const text = await response.text();
const parsed = response.ok
? parseLibraryBody(entry.source.format, text, entry.source)
: null;
return {
id: entry.id,
ok: Boolean(parsed?.candidates.length),
status: response.status,
ms: Date.now() - started,
features: parsed?.candidates.length ?? 0,
};
} catch (error) {
return {
id: entry.id,
ok: false,
status: null,
ms: Date.now() - started,
error: error.message,
};
}
}
const [command = 'check', ...rest] = process.argv.slice(2);
const flag = (name) => rest.includes(name);
const option = (name) => {
const index = rest.indexOf(name);
return index >= 0 ? rest[index + 1] : null;
};
if (command === 'check') check(option('--base-ref'));
else if (command === 'next') {
const id = rest.find((arg) => !arg.startsWith('--'));
if (!id) throw new Error('usage: npm run library:next -- <entry-id>');
if (LIBRARY_LEDGER.some(([existing]) => existing === id))
throw new Error(`${id} already owns a token`);
console.log(nextLibraryToken(LIBRARY_LEDGER));
} else if (command === 'docs') {
const text = readFileSync(DATA_SOURCES, 'utf8');
writeFileSync(DATA_SOURCES, replaceSection(text, libraryDocsSection()));
console.log('DATA_SOURCES.md Data Library section updated.');
} else if (command === 'probe') {
const results = [];
for (const entry of LIBRARY_ENTRIES.filter(({ kind }) => kind !== 'pack'))
results.push(await probeEntry(entry));
if (flag('--json')) console.log(JSON.stringify(results, null, 2));
else
for (const result of results)
console.log(
`${result.ok ? 'ok ' : 'FAIL'} ${result.id} · ${result.status ?? '—'} · ${result.ms} ms${result.features !== undefined ? ` · ${result.features} features` : ''}${result.partitions !== undefined ? ` in the first of ${result.partitions} partitions` : ''}${result.error ? ` · ${result.error}` : ''}`,
);
if (flag('--strict') && results.some(({ ok }) => !ok)) process.exit(1);
} else {
console.error(`unknown command: ${command}`);
process.exit(2);
}
+157 -13
View File
@@ -363,9 +363,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",
@@ -809,7 +806,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 +814,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 +833,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"],
@@ -1011,8 +1006,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",
@@ -1204,7 +1197,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",
@@ -2344,7 +2336,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",
@@ -2816,7 +2807,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",
@@ -3218,6 +3208,155 @@
"src/voice/geminiSession.js", "src/voice/geminiSession.js",
"src/voice/geminiAudio.js", "src/voice/geminiAudio.js",
"src/voice/geminiCaptureWorklet.js", "src/voice/geminiCaptureWorklet.js",
"src/ui/libraryBrowser.js",
"src/library/manifest.js",
"src/library/selection.js",
"src/library/ledger.json",
"src/library/entries/ak-statewide-imagery.json",
"src/library/entries/amsterdam-traffic-cameras.json",
"src/library/entries/ar-statewide-imagery.json",
"src/library/entries/az-statewide-imagery.json",
"src/library/entries/bangor-me.json",
"src/library/entries/basemap-at-orthofoto.json",
"src/library/entries/bayern-dop40.json",
"src/library/entries/bismarck-mandan-nd.json",
"src/library/entries/bkg-topplus-open.json",
"src/library/entries/cctv-florida-fl511.json",
"src/library/entries/cctv-lithuania.json",
"src/library/entries/cctv-nyc-dot.json",
"src/library/entries/ct-statewide-imagery.json",
"src/library/entries/dakota-county-mn.json",
"src/library/entries/dc-statewide-imagery.json",
"src/library/entries/de-statewide-imagery.json",
"src/library/entries/ee-maa-amet-ortofoto.json",
"src/library/entries/eia-power-plants.json",
"src/library/entries/emodnet-bathymetry.json",
"src/library/entries/epa-superfund-sites.json",
"src/library/entries/epa-toxic-release-facilities.json",
"src/library/entries/epc-england-wales.json",
"src/library/entries/fema-usa-structures.json",
"src/library/entries/gbif-occurrence-density.json",
"src/library/entries/gdacs-disaster-alerts.json",
"src/library/entries/geneva-sitg-ortho.json",
"src/library/entries/gpw-population-density.json",
"src/library/entries/grand-forks-nd.json",
"src/library/entries/gsi-japan-map.json",
"src/library/entries/gsi-japan-photo.json",
"src/library/entries/gvp-holocene-volcanoes.json",
"src/library/entries/ia-statewide-imagery.json",
"src/library/entries/icgc-orto-catalunya.json",
"src/library/entries/id-statewide-imagery.json",
"src/library/entries/ign-orthophotos-france.json",
"src/library/entries/il-statewide-imagery.json",
"src/library/entries/imf-portwatch-chokepoints.json",
"src/library/entries/imf-portwatch-disruptions.json",
"src/library/entries/in-statewide-imagery.json",
"src/library/entries/jrc-surface-water.json",
"src/library/entries/kartverket-admin-units.json",
"src/library/entries/kartverket-cadastre.json",
"src/library/entries/kartverket-depth.json",
"src/library/entries/kartverket-hillshade.json",
"src/library/entries/kartverket-nautical-charts.json",
"src/library/entries/kartverket-terrain-relief.json",
"src/library/entries/kartverket-topo-grey.json",
"src/library/entries/kartverket-topo.json",
"src/library/entries/kartverket-toporaster.json",
"src/library/entries/ks-statewide-imagery.json",
"src/library/entries/ky-statewide-imagery.json",
"src/library/entries/lake-county-in.json",
"src/library/entries/lawrence-ks.json",
"src/library/entries/lee-county-fl.json",
"src/library/entries/loudoun-county-va.json",
"src/library/entries/louisville-lojic-ky.json",
"src/library/entries/lu-ortho-latest.json",
"src/library/entries/ma-statewide-imagery.json",
"src/library/entries/macrostrat-geology.json",
"src/library/entries/mapterhorn-terrain.json",
"src/library/entries/maris-ms.json",
"src/library/entries/md-statewide-imagery.json",
"src/library/entries/me-statewide-imagery.json",
"src/library/entries/mi-statewide-imagery.json",
"src/library/entries/mn-statewide-imagery.json",
"src/library/entries/mountain-village-co.json",
"src/library/entries/ms-statewide-imagery.json",
"src/library/entries/mt-statewide-imagery.json",
"src/library/entries/naip.json",
"src/library/entries/nasa-black-marble.json",
"src/library/entries/nasa-eonet-events.json",
"src/library/entries/nc-statewide-imagery.json",
"src/library/entries/nctcog-tx.json",
"src/library/entries/nd-statewide-imagery.json",
"src/library/entries/new-orleans-rpc-la.json",
"src/library/entries/nh-statewide-imagery.json",
"src/library/entries/nj-statewide-imagery.json",
"src/library/entries/noaa-tide-stations.json",
"src/library/entries/nrw-dop.json",
"src/library/entries/nsw-imagery.json",
"src/library/entries/ntad-national-bridges.json",
"src/library/entries/nwa-benton-washington-ar.json",
"src/library/entries/nws-active-alerts.json",
"src/library/entries/ny-statewide-imagery.json",
"src/library/entries/oakland-county-mi.json",
"src/library/entries/oh-statewide-imagery.json",
"src/library/entries/openseamap-seamarks.json",
"src/library/entries/or-statewide-imagery.json",
"src/library/entries/pa-statewide-imagery.json",
"src/library/entries/pdok-aerial-netherlands.json",
"src/library/entries/pl-ortofotomapa.json",
"src/library/entries/pnoa-ma.json",
"src/library/entries/portland-metro-or.json",
"src/library/entries/pt-dgt-ortos.json",
"src/library/entries/ri-statewide-imagery.json",
"src/library/entries/swisstopo-swissimage.json",
"src/library/entries/tx-statewide-imagery.json",
"src/library/entries/union-county-oh.json",
"src/library/entries/usgs-hydrography.json",
"src/library/entries/usgs-topo.json",
"src/library/entries/va-statewide-imagery.json",
"src/library/entries/vlaanderen-omw.json",
"src/library/entries/vt-statewide-imagery.json",
"src/library/entries/wallonie-ortho.json",
"src/library/entries/wi-statewide-imagery.json",
"src/library/entries/wri-global-power-plants.json",
"src/library/entries/wv-statewide-imagery.json",
"src/library/formats.js",
"src/library/entries/gvp-holocene-volcanoes.json",
"src/library/entries/nasa-black-marble.json",
"src/library/entries/nasa-eonet-events.json",
"src/library/entries/openseamap-seamarks.json",
"src/library/catalog.js",
"src/library/source.js",
"src/library/layers.js",
"src/library/controller.js",
"src/library/extent.js",
"src/library/camera.js",
"src/library/entries/imf-portwatch-disruptions.json",
"src/library/entries/imf-portwatch-chokepoints.json",
"src/library/entries/noaa-tide-stations.json",
"src/library/entries/nws-active-alerts.json",
"src/library/entries/wri-global-power-plants.json",
"src/library/entries/fema-usa-structures.json",
"src/library/entries/epc-england-wales.json",
"src/library/parquet.js",
"src/library/entries/bkg-topplus-open.json",
"src/library/entries/emodnet-bathymetry.json",
"src/library/entries/epa-superfund-sites.json",
"src/library/entries/epa-toxic-release-facilities.json",
"src/library/entries/gbif-occurrence-density.json",
"src/library/entries/gpw-population-density.json",
"src/library/entries/gdacs-disaster-alerts.json",
"src/library/entries/gsi-japan-map.json",
"src/library/entries/ign-orthophotos-france.json",
"src/library/entries/gsi-japan-photo.json",
"src/library/entries/jrc-surface-water.json",
"src/library/entries/ntad-national-bridges.json",
"src/library/entries/usgs-hydrography.json",
"src/library/entries/macrostrat-geology.json",
"src/library/entries/pdok-aerial-netherlands.json",
"src/library/entries/swisstopo-swissimage.json",
"src/library/entries/usgs-topo.json",
"src/library/outlines.js",
"src/library/relief.js",
"src/sceneCommandRelease.js", "src/sceneCommandRelease.js",
"src/scopeMaskPolicy.js", "src/scopeMaskPolicy.js",
"src/visualStylePolicy.js", "src/visualStylePolicy.js",
@@ -3341,6 +3480,8 @@
"satellite.js", "satellite.js",
"mapillary-js", "mapillary-js",
"@jtarrio/webrtlsdr", "@jtarrio/webrtlsdr",
"hyparquet",
"fzstd",
"vue", "vue",
"pinia", "pinia",
"vue-i18n" "vue-i18n"
@@ -3390,7 +3531,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",
@@ -4079,7 +4219,11 @@
}, },
"layer-lifecycle": { "layer-lifecycle": {
"exports": ["./data/lifecycle"], "exports": ["./data/lifecycle"],
"modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"], "modules": [
"src/data/lifecycle.js",
"src/data/cameraArrival.js",
"src/library/manifest.js"
],
"external": [] "external": []
}, },
"ui-navigation": { "ui-navigation": {
+636
View File
@@ -0,0 +1,636 @@
#!/usr/bin/env node
/**
* Data Library live gate. Run against this candidate's dev or preview server:
*
* npm run qa:data-library -- --url http://localhost:4173
*
* Every upstream a library entry names is answered from fixtures in the
* browser (EONET, the EIA ArcGIS pages, the GIBS tiles and the proxied
* volcano route), so the gate is deterministic and offline apart from the
* app itself. It drives the real panel and dialog: Browse opens a modal,
* Add registers a layer under the Library heading and switches it on, the
* share link carries `lib=`, a reload restores the selection, Escape and
* clean view close the dialog, and Remove destroys the layer.
*/
import assert from 'node:assert/strict';
const { default: puppeteer } = await import('puppeteer');
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const base = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
const point = (lon, lat, properties) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties,
});
const collection = (features, extra = {}) =>
JSON.stringify({ type: 'FeatureCollection', features, ...extra });
// 1×1 transparent PNG.
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
);
const seen = {
eonet: 0,
arcgisPages: [],
tiles: 0,
proxied: 0,
structureBoxes: [],
};
function fixtureFor(url) {
const cors = { 'Access-Control-Allow-Origin': '*' };
if (url.hostname === 'eonet.gsfc.nasa.gov') {
seen.eonet += 1;
return {
status: 200,
headers: cors,
contentType: 'application/json',
body: collection([
point(-120, 38, {
title: 'Fixture Fire',
date: '2026-10-20T00:00:00Z',
}),
point(140, 35, {
title: 'Fixture Storm',
date: '2026-10-19T00:00:00Z',
}),
]),
};
}
if (
url.hostname === 'services2.arcgis.com' &&
url.pathname.includes('USA_Structures')
) {
seen.structureBoxes.push(url.searchParams.get('geometry'));
const square = (lon, lat) => ({
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[lon, lat],
[lon + 0.0004, lat],
[lon + 0.0004, lat + 0.0003],
[lon, lat + 0.0003],
[lon, lat],
],
],
},
properties: { OCC_CLS: 'Government', HEIGHT: 30 },
});
return {
status: 200,
headers: cors,
contentType: 'application/json',
body: collection([
square(-77.035, 38.892),
square(-77.03, 38.893),
square(-77.025, 38.894),
]),
};
}
if (url.hostname === 'services2.arcgis.com') {
const offset = Number(url.searchParams.get('resultOffset'));
seen.arcgisPages.push(offset);
const features =
offset === 0
? [
point(-118.9, 47.9, { Plant_Name: 'Fixture Dam', Total_MW: 6809 }),
point(-112.9, 33.4, {
Plant_Name: 'Fixture Nuclear',
Total_MW: 3937,
}),
]
: [point(-97.6, 28.8, { Plant_Name: 'Fixture Coast', Total_MW: 2560 })];
return {
status: 200,
headers: cors,
contentType: 'application/json',
body: collection(features, {
properties: { exceededTransferLimit: offset === 0 },
}),
};
}
if (url.hostname === 'gibs.earthdata.nasa.gov') {
seen.tiles += 1;
return { status: 200, headers: cors, contentType: 'image/png', body: PNG };
}
if (url.pathname === '/api/library/data/gvp-holocene-volcanoes') {
seen.proxied += 1;
return {
status: 200,
contentType: 'application/json',
body: collection([
point(-121.76, 46.85, { name: 'Fixture Volcano', __gevDetails: [] }),
]),
};
}
return null;
}
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()),
defaultViewport: { width: 1440, height: 900 },
protocolTimeout: 300_000,
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
const errors = [];
let count = 0;
const step = async (label, fn) => {
await fn();
console.log(`ok ${++count} ${label}`);
};
async function open(page, hash = '') {
await page.goto(`${base}/${hash}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__gevDataLibrary?.library.ready &&
window.__godsEyeView?.dataManager &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 180_000 },
);
}
const libraryRows = (page) =>
page.evaluate(() =>
[...document.querySelectorAll('#data-toggles [data-library-entry]')].map(
(row) => ({
id: row.dataset.libraryEntry,
on: row.querySelector('.data-toggle-btn')?.classList.contains('active'),
count: row.querySelector('.data-count')?.textContent,
}),
),
);
try {
const page = await browser.newPage();
page.on('console', (message) => {
if (message.type() === 'error' && /Library|library/.test(message.text()))
errors.push(message.text());
});
page.on('pageerror', (error) => errors.push(String(error)));
await page.setRequestInterception(true);
page.on('request', (request) => {
const fixture = fixtureFor(new URL(request.url()));
if (fixture) request.respond(fixture);
else request.continue();
});
await page.evaluateOnNewDocument(() => {
try {
// Start empty once; the reload step needs what this run stored.
if (!sessionStorage.getItem('qa-data-library')) {
localStorage.removeItem('gev:library:v1');
sessionStorage.setItem('qa-data-library', '1');
}
// Skip the first-run chooser (src/firstRunExperience.js).
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
} catch {
/* storage may be blocked; the gate still runs */
}
});
await open(page);
await step(
'the panel offers Browse and lists no library layer by default',
async () => {
assert.deepEqual(await libraryRows(page), []);
const hidden = await page.$eval(
'#data-library-browse',
(node) => node.hidden,
);
assert.equal(hidden, false);
},
);
await step(
'Browse opens a labelled modal with addable entries first and packs last',
async () => {
await page.evaluate(() => {
document.getElementById('data-panel')?.classList.remove('collapsed');
document.getElementById('data-library-browse').click();
});
const dialog = await page.$eval('#data-library', (node) => ({
open: node.open,
modal: node.matches(':modal'),
title: document.getElementById(node.getAttribute('aria-labelledby'))
?.textContent,
focus: document.activeElement?.type,
order: [...node.querySelectorAll('.data-library-item')].map(
(item) => item.dataset.libraryId,
),
}));
assert.equal(dialog.open, true);
assert.equal(dialog.modal, true);
assert.equal(dialog.title, 'DATA LIBRARY');
assert.equal(dialog.focus, 'search');
assert.ok(dialog.order.at(-1).startsWith('cctv-'), dialog.order.join());
assert.ok(!dialog.order[0].startsWith('cctv-'));
},
);
await step(
'a pack explains the operator switch instead of offering Add',
async () => {
const pack = await page.$eval(
'#data-library .data-library-item[data-library-id="cctv-nyc-dot"]',
(item) => ({
action: Boolean(item.querySelector('.data-library-action')),
reason: item.querySelector('.data-library-reason')?.textContent,
}),
);
assert.equal(pack.action, false);
assert.match(pack.reason, /CCTV_NYC_ENABLED=1/);
},
);
for (const [id, expected] of [
['nasa-eonet-events', '2'],
['eia-power-plants', '3'],
['gvp-holocene-volcanoes', '1'],
['nasa-black-marble', 'DRAPED'],
])
await step(
`Add ${id} registers it under Library and switches it on`,
async () => {
await page.click(
`#data-library button[data-library-id="${id}"][data-library-action="add"]`,
);
await page.waitForFunction(
(layerId, text) => {
const row = document.querySelector(
`#data-toggles [data-layer-id="${layerId}"]`,
);
return (
row
?.querySelector('.data-toggle-btn')
?.classList.contains('active') &&
row.querySelector('.data-count')?.textContent === text
);
},
{ timeout: 60_000 },
`lib-${id}`,
expected,
);
},
);
await step(
'the fixtures were reached the way each manifest says',
async () => {
assert.ok(seen.eonet >= 1);
assert.deepEqual(seen.arcgisPages.slice(0, 2), [0, 2]);
assert.ok(seen.proxied >= 1);
const draped = await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('lib-nasa-black-marble')
.module.getDiagnostics().draped,
);
assert.equal(draped, true);
// Imagery requests tiles on the following frames, not on enable.
for (let waited = 0; seen.tiles === 0 && waited < 30_000; waited += 250)
await new Promise((resolve) => setTimeout(resolve, 250));
assert.ok(seen.tiles >= 1, 'the drape requested its tiles');
const headings = await page.$$eval(
'#data-toggles .data-layer-group-heading',
(nodes) => nodes.map((node) => node.textContent),
);
assert.equal(headings.at(-1), 'Library');
},
);
await step(
'the stats strip sits above the intro and counts what is on the map',
async () => {
await page.waitForFunction(
() =>
document.querySelector(
'[data-stat="features"] .data-library-stat-value',
)?.textContent === '6',
{ timeout: 15_000 },
);
const strip = await page.evaluate(() => {
const stats = document.querySelector(
'#data-library .data-library-stats',
);
const read = (id) =>
[...document.querySelectorAll(`[data-stat="${id}"] dd`)].map(
(node) => node.textContent,
);
return {
beforeIntro:
stats?.nextElementSibling?.classList.contains('data-library-intro'),
label: stats?.getAttribute('aria-label'),
entries: read('entries'),
added: read('added'),
features: read('features'),
};
});
assert.equal(strip.beforeIntro, true);
assert.equal(strip.label, 'Data Library at a glance');
// Counted from the shipped catalog, so a new entry does not break this.
const kinds = await page.evaluate(() => {
const counts = { dataset: 0, tiles: 0, pack: 0 };
for (const { entry } of window.__gevDataLibrary.library.list())
counts[entry.kind] += 1;
return counts;
});
assert.deepEqual(strip.entries, [
String(kinds.dataset + kinds.tiles + kinds.pack),
`${kinds.dataset} datasets · ${kinds.tiles} tile layers · ${kinds.pack} packs`,
]);
assert.deepEqual(strip.added, ['4', '4 on the map']);
// Fixtures: 2 EONET events, 3 power plants, 1 volcano.
assert.deepEqual(strip.features, ['6', 'from 3 datasets']);
},
);
await step('the share link carries lib= with the ledger tokens', async () => {
await page.waitForFunction(
() => /(?:^|&)lib=00\.02\.01\.03(?:&|$)/.test(location.hash.slice(1)),
{ timeout: 15_000 },
);
});
await step(
'Escape closes the dialog and returns focus to Browse',
async () => {
await page.focus('#data-library input[type="search"]');
await page.keyboard.press('Escape');
const state = await page.evaluate(() => ({
open: document.getElementById('data-library').open,
focus: document.activeElement?.id,
}));
assert.deepEqual(state, { open: false, focus: 'data-library-browse' });
},
);
await step('clean view closes an open dialog', async () => {
const open = await page.evaluate(async () => {
window.__gevDataLibrary.browser.open();
document.body.classList.add('ui-clean-view');
await new Promise((resolve) => setTimeout(resolve, 50));
const result = document.getElementById('data-library').open;
document.body.classList.remove('ui-clean-view');
return result;
});
assert.equal(open, false);
});
await step(
'a reload restores the added entries and what was on',
async () => {
await page.evaluate(() => {
document
.querySelector(
'#data-toggles [data-layer-id="lib-gvp-holocene-volcanoes"] .data-toggle-btn',
)
.click();
});
await page.waitForFunction(
() =>
JSON.parse(localStorage.getItem('gev:library:v1')).on.length === 3,
);
await open(page, '');
await page.waitForFunction(
() =>
document.querySelectorAll('#data-toggles [data-library-entry]')
.length === 4,
{ timeout: 60_000 },
);
const rows = await libraryRows(page);
assert.deepEqual(rows.map(({ id, on }) => [id, on]).sort(), [
['eia-power-plants', true],
['gvp-holocene-volcanoes', false],
['nasa-black-marble', true],
['nasa-eonet-events', true],
]);
},
);
const cameraCenter = () =>
page.evaluate(() => {
const { camera } = window.__godsEyeView.viewer;
const carto = camera.positionCartographic;
return {
lon: (carto.longitude * 180) / Math.PI,
lat: (carto.latitude * 180) / Math.PI,
height: carto.height,
};
});
const waitForFlight = () =>
page.waitForFunction(
() => !window.__godsEyeView.viewer.camera._currentFlight,
{ timeout: 30_000, polling: 200 },
);
await step(
'Zoom to layer on the row works while off: it switches on and flies',
async () => {
const chip =
'#data-toggles [data-layer-id="lib-eia-power-plants"] [data-chip-id="zoom"]';
await page.evaluate(() =>
document.getElementById('data-panel')?.classList.remove('collapsed'),
);
// The icons work while the layer is off; Zoom switches it back on.
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled(
'lib-eia-power-plants',
false,
{ origin: 'user' },
),
);
await page.waitForSelector(chip, { visible: true, timeout: 30_000 });
await page.evaluate(
(selector) => document.querySelector(selector).click(),
chip,
);
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.isEnabled('lib-eia-power-plants'),
{ timeout: 30_000 },
);
await new Promise((resolve) => setTimeout(resolve, 300));
await waitForFlight();
const center = await cameraCenter();
// Fixture plants span -118.9…-97.6 °E and 28.8…47.9 °N.
assert.ok(center.lon > -125 && center.lon < -90, JSON.stringify(center));
assert.ok(center.lat > 22 && center.lat < 55, JSON.stringify(center));
},
);
await step(
'ZOOM in the dialog closes it and flies to that entry',
async () => {
await page.evaluate(() => window.__gevDataLibrary.browser.open());
const zoom =
'#data-library button[data-library-id="gvp-holocene-volcanoes"][data-library-action="zoom"]';
assert.ok(await page.$(zoom), 'Zoom is offered for an entry that is off');
await page.click(
'#data-library button[data-library-id="nasa-eonet-events"][data-library-action="zoom"]',
);
const open = await page.$eval('#data-library', (node) => node.open);
assert.equal(open, false);
await new Promise((resolve) => setTimeout(resolve, 300));
await waitForFlight();
const center = await cameraCenter();
// Fixture events at (-120, 38) and (140, 35): the frame sits between them.
assert.ok(center.lat > 20 && center.lat < 55, JSON.stringify(center));
},
);
await step('Remove destroys the layer and forgets it', async () => {
await page.evaluate(() => window.__gevDataLibrary.browser.open());
await page.click(
'#data-library button[data-library-id="nasa-black-marble"][data-library-action="remove"]',
);
await page.waitForFunction(
() =>
!document.querySelector(
'#data-toggles [data-layer-id="lib-nasa-black-marble"]',
) &&
!window.__godsEyeView.dataManager.layers.has('lib-nasa-black-marble'),
{ timeout: 30_000 },
);
const stored = await page.evaluate(() =>
JSON.parse(localStorage.getItem('gev:library:v1')),
);
assert.equal(stored.added.includes('nasa-black-marble'), false);
});
await step(
'the row delete icon removes that entry, even while it is off',
async () => {
await page.evaluate(() => {
window.__gevDataLibrary.browser.close();
document.getElementById('data-panel')?.classList.remove('collapsed');
});
const chip =
'#data-toggles [data-layer-id="lib-eia-power-plants"] [data-chip-id="remove"]';
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled(
'lib-eia-power-plants',
false,
{ origin: 'user' },
),
);
await page.waitForSelector(chip, { visible: true, timeout: 30_000 });
const label = await page.$eval(chip, (node) => ({
name: node.getAttribute('aria-label'),
glyph: node.textContent,
pressed: node.getAttribute('aria-pressed'),
}));
assert.deepEqual(label, {
name: 'Remove from Data Layers',
glyph: 'delete',
pressed: null,
});
await page.evaluate(
(selector) => document.querySelector(selector).click(),
chip,
);
await page.waitForFunction(
() =>
!window.__godsEyeView.dataManager.layers.has(
'lib-eia-power-plants',
) &&
!document.querySelector(
'#data-toggles [data-layer-id="lib-eia-power-plants"]',
),
{ timeout: 30_000 },
);
},
);
await step(
'a view-bounded layer asks to zoom in, then loads only what is in view',
async () => {
await page.evaluate(() =>
window.__gevDataLibrary.library.add('fema-usa-structures'),
);
const row = '#data-toggles [data-layer-id="lib-fema-usa-structures"]';
await page.waitForFunction(
(selector) =>
/zoom in to load this layer/.test(
document.querySelector(`${selector} .data-toggle-meta`)
?.textContent || '',
),
{ timeout: 30_000 },
row,
);
assert.equal(seen.structureBoxes.length, 0, 'nothing loads from orbit');
await page.evaluate(() =>
window.__gevDataLibrary.library.zoom('fema-usa-structures'),
);
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers
.get('lib-fema-usa-structures')
.module.getDiagnostics().count === 3,
{ timeout: 60_000, polling: 250 },
);
const altitude = (await cameraCenter()).height;
assert.ok(altitude <= 4000, `landed at ${Math.round(altitude)} m`);
const [west, south, east, north] = seen.structureBoxes
.at(-1)
.split(',')
.map(Number);
assert.ok(east - west < 0.2 && north - south < 0.2, 'a view-sized box');
assert.ok(
west < -77.03 && east > -77.03 && south < 38.893 && north > 38.893,
);
// Climbing back out clears the buildings and shows the guidance again.
await page.evaluate(() => {
const { viewer, requestRender } = window.__godsEyeView;
viewer.camera.zoomOut(60_000);
requestRender?.('qa-data-library');
});
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers
.get('lib-fema-usa-structures')
.module.getDiagnostics().tooHigh,
{ timeout: 30_000, polling: 250 },
);
},
);
await step('Remove all asks twice, then clears the library', async () => {
await page.evaluate(() => window.__gevDataLibrary.browser.open());
await page.click('#data-library .data-library-reset');
const armed = await page.$eval(
'#data-library .data-library-reset',
(node) => ({
text: node.textContent,
armed: node.classList.contains('is-armed'),
}),
);
assert.equal(armed.armed, true);
assert.match(armed.text, /CONFIRM/);
assert.ok(
(await page.$$('#data-toggles [data-library-entry]')).length > 0,
'one press removes nothing',
);
await page.click('#data-library .data-library-reset');
await page.waitForFunction(
() =>
!document.querySelector('#data-toggles [data-library-entry]') &&
document.querySelector('#data-library .data-library-reset').hidden,
{ timeout: 30_000 },
);
const stored = await page.evaluate(() =>
JSON.parse(localStorage.getItem('gev:library:v1')),
);
assert.deepEqual(stored, { added: [], on: [] });
});
assert.deepEqual(errors, [], 'no library errors on the page');
console.log(`# ${count} passed`);
} finally {
await browser.close();
}
-73
View File
@@ -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;
+1 -33
View File
@@ -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(
+5 -187
View File
@@ -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,
+12 -49
View File
@@ -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,
+2 -34
View File
@@ -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,
@@ -83,11 +75,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 +110,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'),
@@ -190,18 +172,6 @@ const LIVE_PACKS = [
enabled: () => envOptIn('CCTV_HOUSTON_ENABLED'), enabled: () => envOptIn('CCTV_HOUSTON_ENABLED'),
load: loadHoustonSourcesFromOpenData, 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 +316,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 +326,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 +334,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),
+7 -74
View File
@@ -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 =
+6 -243
View File
@@ -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 {
-15
View File
@@ -460,17 +460,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 ("&#237;") and hex * Decode numeric HTML character references, decimal ("&#237;") and hex
* ("&#xED;") alike, the entity form the Catalonia feed uses for accented * ("&#xED;") alike, the entity form the Catalonia feed uses for accented
@@ -650,10 +639,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.
-185
View File
@@ -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 [];
}
}
-138
View File
@@ -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;
}
-175
View File
@@ -134,12 +134,6 @@ import {
HOUSTON_MAX_CATALOG_BYTES, HOUSTON_MAX_CATALOG_BYTES,
HOUSTON_GROUND_ELEVATION_M, HOUSTON_GROUND_ELEVATION_M,
HOUSTON_ANCHORS, 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,7 +152,6 @@ import {
isLikelyCalgaryCoordinate, isLikelyCalgaryCoordinate,
isLikelyFloridaCoordinate, isLikelyFloridaCoordinate,
isLikelyCataloniaCoordinate, isLikelyCataloniaCoordinate,
isLikelyIcelandCoordinate,
decodeNumericEntities, decodeNumericEntities,
isLikelyNycCoordinate, isLikelyNycCoordinate,
isLikelyHoustonCoordinate, isLikelyHoustonCoordinate,
@@ -2036,174 +2029,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.
* *
+1 -3
View File
@@ -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
View File
@@ -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 [];
}
} }
} }
-132
View File
@@ -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;
}
+363
View File
@@ -0,0 +1,363 @@
import { LIBRARY_ENTRIES } from '../../src/library/catalog.js';
import { loadLibraryDataset } from '../../src/library/formats.js';
import { buildPartitionIndex } from '../../src/library/parquet.js';
import {
libraryUpstreamUrl,
parseLibraryIdList,
libraryTileUrl,
} from '../../src/library/manifest.js';
import { KEY_SETUP_KEYS } from '../../src/keySetupCore.mjs';
import {
readResponseBytesCapped,
readResponseTextCapped,
coalesceProxyRequest,
} from './common/http.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import { admitSameSite, admitSameSiteKeyless } from './common/same-site.js';
const DATASET_TIMEOUT_MS = 30_000;
const TILE_TIMEOUT_MS = 10_000;
/** Each listing page or footer read while building a partition index. */
const INDEX_TIMEOUT_MS = 20_000;
const TILE_MAX_BYTES = 2 * 1024 * 1024;
const TILE_CACHE_LIMIT = 512;
const IN_FLIGHT_LIMIT = 64;
/**
* Availability of every entry on this instance, decided by the operator's
* environment alone: `GEV_LIBRARY_DISABLE` removes entries, opt-in entries
* need `GEV_LIBRARY_ENABLE` (or, for a pack, its host's own switch), and a
* keyed entry needs its key. The answer names a reason, never a secret.
* @param {object[]} entries
* @param {Record<string, string|undefined>} env
* @returns {Map<string, { available: boolean, reason: string|null }>}
*/
export function libraryAvailability(entries, env) {
const enabled = parseLibraryIdList(env.GEV_LIBRARY_ENABLE);
const disabled = parseLibraryIdList(env.GEV_LIBRARY_DISABLE);
const result = new Map();
for (const entry of entries) {
let reason = null;
if (disabled.has(entry.id)) reason = 'operator-disabled';
else if (entry.kind === 'pack') {
if (String(env[entry.pack.env] || '').trim() !== '1')
reason = 'operator-opt-in';
} else if (entry.licence.operatorOptIn && !enabled.has(entry.id))
reason = 'operator-opt-in';
else if (entry.source.key && !libraryKeyValue(entry.source.key, env))
reason = 'key-missing';
result.set(entry.id, { available: reason === null, reason });
}
return result;
}
/** The configured value for a manifest key, through the key registry. */
export function libraryKeyValue(key, env) {
const registered = KEY_SETUP_KEYS.find(({ id }) => id === key?.id);
for (const name of registered?.envVars || []) {
const value = String(env[name] || '').trim();
if (value) return value;
}
return null;
}
function withKey(url, key, env, headers) {
if (!key) return url;
const value = libraryKeyValue(key, env);
if (!value) throw Object.assign(new Error('key_missing'), { status: 503 });
if (key.in === 'header') {
headers[key.name] = value;
return url;
}
const keyed = new URL(url);
keyed.searchParams.set(key.name, value);
return keyed.href;
}
/**
* Parse `/<id>/<z>/<x>/<y>` against a tiles entry's zoom range.
* @returns {{ z: number, x: number, y: number } | null}
*/
export function parseLibraryTilePath(parts, source) {
if (parts.length !== 3 || !parts.every((part) => /^\d{1,8}$/.test(part)))
return null;
const [z, x, y] = parts.map(Number);
const minZoom = source.minZoom ?? 0;
const maxZoom = source.maxZoom ?? 22;
if (z < minZoom || z > maxZoom) return null;
const span = 2 ** z;
if (x >= span || y >= span) return null;
return { z, x, y };
}
/**
* The Data Library's one server route. Proxied datasets and tiles reach their
* manifest's fixed upstream only: the browser names an entry id, never a URL,
* so the allowlist is exactly the catalog. Redirects are refused, bodies are
* capped while streaming, keys come from this server's environment and
* upstream error text is never returned.
*/
export function libraryProxy({
entries = LIBRARY_ENTRIES,
env = process.env,
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
} = {}) {
const byId = new Map(entries.map((entry) => [entry.id, entry]));
const datasets = new Map();
const tiles = new Map();
const indexes = new Map();
const inFlight = new Map();
const allowData = makeRateLimiter({
windowMs: 60_000,
max: 30,
globalMax: 600,
});
const allowTiles = makeRateLimiter({
windowMs: 60_000,
max: 600,
globalMax: 6000,
});
async function upstream(url, entry, headers, timeoutMs) {
// Defense in depth: every derived request stays on the manifest's host.
const target = libraryUpstreamUrl(url);
const declared = libraryUpstreamUrl(entry.source.url);
if (!target || !declared || target.host !== declared.host)
throw Object.assign(new Error('off_manifest_host'), { status: 502 });
const signal = AbortSignal.timeout(timeoutMs);
const response = await fetchImpl(url, {
signal,
redirect: 'error',
headers: { Accept: '*/*', ...headers },
});
if (!response.ok) {
await response.body?.cancel?.().catch?.(() => {});
throw Object.assign(new Error('upstream_unavailable'), { status: 502 });
}
return { response, signal };
}
async function fetchDataset(entry) {
const headers = {};
const collection = await loadLibraryDataset(entry, async (url, budget) => {
const keyed = withKey(url, entry.source.key, env, headers);
const { response, signal } = await upstream(
keyed,
entry,
headers,
DATASET_TIMEOUT_MS,
);
const text = await readResponseTextCapped(
response,
budget.remainingBytes,
signal,
);
budget.remainingBytes -= new TextEncoder().encode(text).byteLength;
return text;
});
return { ...collection, fetchedAt: now() };
}
async function cached(store, key, ttlMs, load, limit = Infinity) {
const previous = store.get(key);
if (previous && now() - previous.savedAt < ttlMs)
return { value: previous.value, stale: false };
if (!inFlight.has(key) && inFlight.size >= IN_FLIGHT_LIMIT)
throw Object.assign(new Error('busy'), { status: 429 });
try {
const { promise } = coalesceProxyRequest(inFlight, key, async () => {
const value = await load();
store.delete(key);
store.set(key, { value, savedAt: now() });
while (store.size > limit) store.delete(store.keys().next().value);
return value;
});
return { value: await promise, stale: false };
} catch (error) {
if (previous) return { value: previous.value, stale: true };
throw error;
}
}
function json(res, status, value, extra = {}) {
if (res.destroyed) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 405 ? { Allow: 'GET' } : {}),
...(status === 429 ? { 'Retry-After': '60' } : {}),
...extra,
});
res.end(JSON.stringify(value));
}
function gate(req, res, entry) {
// A keyed entry spends the operator's credential: refuse forwarded
// requests like the other cost-bearing routes. Keyless entries keep
// working behind a reverse proxy, like terrain heights.
return entry?.source?.key
? admitSameSite(req, res)
: admitSameSiteKeyless(req, res);
}
async function handler(req, res) {
if (req.method !== 'GET')
return json(res, 405, { error: 'method_not_allowed' });
const path = (req.url || '/').split('?')[0];
const parts = path.split('/').filter(Boolean);
const availability = libraryAvailability(entries, env);
if (parts.length === 1 && parts[0] === 'catalog') {
if (admitSameSiteKeyless(req, res)) return undefined;
return json(res, 200, {
entries: entries.map(({ id }) => ({ id, ...availability.get(id) })),
});
}
const [route, id, ...rest] = parts;
const entry = byId.get(id);
if (!entry || !['data', 'tile', 'index'].includes(route))
return json(res, 404, { error: 'unknown_entry' });
// A partitioned GeoParquet entry's index: which file covers where, read
// from each file's footer. Built once per cacheSeconds and shared, so
// visitors do not each list the bucket and read every footer.
if (route === 'index') {
if (rest.length || !entry.source?.partitions)
return json(res, 404, { error: 'not_partitioned' });
if (admitSameSiteKeyless(req, res)) return undefined;
const { available, reason } = availability.get(id);
if (!available) return json(res, 403, { error: reason });
if (!allowData(clientKey(req)))
return json(res, 429, { error: 'rate_limited' });
try {
const { value } = await cached(
indexes,
`index:${id}`,
entry.source.cacheSeconds * 1000,
async () => ({
partitions: await buildPartitionIndex(entry, {
fetchImpl: (url, options = {}) =>
fetchImpl(url, {
...options,
signal:
options.signal ?? AbortSignal.timeout(INDEX_TIMEOUT_MS),
}),
}),
builtAt: now(),
}),
);
return json(res, 200, value);
} catch (error) {
const status = error?.status === 429 ? 429 : 502;
return json(res, status, { error: 'library_upstream_unavailable' });
}
}
const isDataset = route === 'data' && rest.length === 0;
const isTile = route === 'tile';
if (
entry.source.fetch !== 'proxied' ||
(isDataset && entry.kind !== 'dataset') ||
(isTile && entry.kind !== 'tiles') ||
(!isDataset && !isTile)
)
return json(res, 404, { error: 'not_proxied' });
if (gate(req, res, entry)) return undefined;
const { available, reason } = availability.get(id);
if (!available) return json(res, 403, { error: reason });
const ttlMs = entry.source.cacheSeconds * 1000;
if (isDataset) {
if (!allowData(clientKey(req)))
return json(res, 429, { error: 'rate_limited' });
try {
const { value, stale } = await cached(
datasets,
`data:${id}`,
ttlMs,
() => fetchDataset(entry),
);
return json(
res,
200,
stale ? { ...value, stale: true } : value,
stale ? { 'X-Data-Stale': 'true' } : {},
);
} catch (error) {
const status = [429, 503].includes(error?.status) ? error.status : 502;
return json(res, status, {
error:
status === 503 ? 'key_missing' : 'library_upstream_unavailable',
});
}
}
const tile = parseLibraryTilePath(rest, entry.source);
if (!tile) return json(res, 400, { error: 'invalid_tile' });
if (!allowTiles(clientKey(req)))
return json(res, 429, { error: 'rate_limited' });
try {
const key = `tile:${id}:${tile.z}/${tile.x}/${tile.y}`;
const { value } = await cached(
tiles,
key,
ttlMs,
async () => {
const headers = {};
const url = withKey(
libraryTileUrl(entry.source.url, tile.z, tile.x, tile.y),
entry.source.key,
env,
headers,
);
const { response, signal } = await upstream(
url,
entry,
headers,
TILE_TIMEOUT_MS,
);
const type = String(response.headers.get('content-type') || '')
.split(';')[0]
.trim()
.toLowerCase();
if (!/^image\/(png|jpeg|webp)$/.test(type)) {
await response.body?.cancel?.().catch?.(() => {});
throw Object.assign(new Error('not_an_image'), { status: 502 });
}
const bytes = await readResponseBytesCapped(
response,
TILE_MAX_BYTES,
signal,
);
return { type, bytes };
},
TILE_CACHE_LIMIT,
);
if (res.destroyed) return undefined;
res.writeHead(200, {
'Content-Type': value.type,
'Cache-Control': `private, max-age=${entry.source.cacheSeconds}`,
'X-Content-Type-Options': 'nosniff',
});
res.end(Buffer.from(value.bytes));
return undefined;
} catch (error) {
const status = [429, 503].includes(error?.status) ? error.status : 502;
return json(res, status, {
error: status === 503 ? 'key_missing' : 'library_upstream_unavailable',
});
}
}
return {
name: 'data-library',
handler,
configureServer({ middlewares }) {
middlewares.use('/api/library', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/library', handler);
},
};
}
+332
View File
@@ -0,0 +1,332 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
libraryAvailability,
libraryProxy,
parseLibraryTilePath,
} from './library.js';
import { validateLibraryCatalog } from '../../src/library/manifest.js';
const base = {
family: 'events',
provider: 'Fixture',
description: 'Fixture entry.',
maintainer: 'test',
coverage: { global: true },
licence: { name: 'Public domain', url: 'https://example.org/licence' },
attribution: { text: 'Fixture' },
status: 'community',
};
const { entries, errors } = validateLibraryCatalog([
{
...base,
id: 'proxied-points',
kind: 'dataset',
title: 'Proxied points',
source: {
format: 'geojson',
url: 'https://data.example.org/points.json',
fetch: 'proxied',
maxBytes: 4096,
},
display: { label: 'title' },
},
{
...base,
id: 'direct-points',
kind: 'dataset',
title: 'Direct points',
source: {
format: 'geojson',
url: 'https://data.example.org/direct.json',
fetch: 'browser-direct',
},
},
{
...base,
id: 'keyed-points',
kind: 'dataset',
title: 'Keyed points',
source: {
format: 'geojson',
url: 'https://keyed.example.org/points.json',
fetch: 'proxied',
key: { id: 'firms', in: 'query', name: 'MAP_KEY' },
},
},
{
...base,
id: 'opt-in-points',
kind: 'dataset',
title: 'Opt-in points',
source: {
format: 'geojson',
url: 'https://data.example.org/opt.json',
fetch: 'proxied',
},
licence: { ...base.licence, operatorOptIn: true },
},
{
...base,
id: 'proxied-tiles',
kind: 'tiles',
title: 'Proxied tiles',
source: {
format: 'xyz',
url: 'https://tiles.example.org/{z}/{x}/{y}.png',
fetch: 'proxied',
minZoom: 2,
maxZoom: 5,
},
},
{
...base,
id: 'fixture-pack',
kind: 'pack',
family: 'cameras',
title: 'Fixture cameras',
source: { format: 'host', url: 'https://cams.example.org/' },
pack: { host: 'cctv', env: 'CCTV_FIXTURE_ENABLED' },
licence: { ...base.licence, operatorOptIn: true },
},
]);
assert.deepEqual(errors, []);
const collection = (features) =>
JSON.stringify({ type: 'FeatureCollection', features });
const point = (title) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [1, 2] },
properties: { title },
});
function install({ env = {}, fetchImpl, now } = {}) {
const plugin = libraryProxy({ entries, env, fetchImpl, now });
let handler;
plugin.configurePreviewServer({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/library');
handler = callback;
},
},
});
return async (url, headers = {}) => {
const res = {
headers: {},
writeHead(status, values) {
this.status = status;
this.headers = values;
},
setHeader(name, value) {
this.headers[name] = value;
},
end(body) {
this.raw = body;
try {
this.body = JSON.parse(body);
} catch {
this.body = null;
}
},
set statusCode(value) {
this.status = value;
},
};
await handler(
{ url, method: 'GET', headers, socket: { remoteAddress: '127.0.0.1' } },
res,
);
return res;
};
}
test('availability is decided by the operator environment and names no secret', () => {
const none = libraryAvailability(entries, {});
assert.deepEqual(none.get('proxied-points'), {
available: true,
reason: null,
});
assert.equal(none.get('keyed-points').reason, 'key-missing');
assert.equal(none.get('opt-in-points').reason, 'operator-opt-in');
assert.equal(none.get('fixture-pack').reason, 'operator-opt-in');
const configured = libraryAvailability(entries, {
FIRMS_MAP_KEY: 'secret',
GEV_LIBRARY_ENABLE: 'opt-in-points',
GEV_LIBRARY_DISABLE: 'proxied-points',
CCTV_FIXTURE_ENABLED: '1',
});
assert.equal(configured.get('keyed-points').available, true);
assert.equal(configured.get('opt-in-points').available, true);
assert.equal(configured.get('proxied-points').reason, 'operator-disabled');
assert.equal(configured.get('fixture-pack').available, true);
assert.doesNotMatch(JSON.stringify([...configured]), /secret/);
});
test('the catalog route lists every entry with its availability', async () => {
const request = install();
const res = await request('/catalog');
assert.equal(res.status, 200);
assert.equal(res.body.entries.length, entries.length);
assert.deepEqual(
res.body.entries.find(({ id }) => id === 'opt-in-points'),
{ id: 'opt-in-points', available: false, reason: 'operator-opt-in' },
);
});
test('a proxied dataset reaches only its manifest URL, refuses redirects and is normalized', async () => {
const calls = [];
const request = install({
now: () => 42,
fetchImpl: async (url, options) => {
calls.push({ url, options });
return new Response(collection([point('Storm'), { geometry: null }]));
},
});
const res = await request('/data/proxied-points?url=https://evil.example');
assert.equal(res.status, 200);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, 'https://data.example.org/points.json');
assert.equal(calls[0].options.redirect, 'error');
assert.ok(calls[0].options.signal instanceof AbortSignal);
assert.equal(res.body.features.length, 1);
assert.equal(res.body.features[0].properties.name, 'Storm');
assert.equal(res.body.dropped, 1);
assert.equal(res.body.fetchedAt, 42);
// A fresh cache answers without another upstream request.
await request('/data/proxied-points');
assert.equal(calls.length, 1);
});
test('browser-direct, unknown, opt-in and wrong-kind entries are not proxied', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return new Response(collection([]));
},
});
assert.equal((await request('/data/direct-points')).status, 404);
assert.equal((await request('/data/nope-nope')).status, 404);
assert.equal((await request('/data/proxied-tiles')).status, 404);
assert.equal((await request('/tile/proxied-points/2/0/0')).status, 404);
const optIn = await request('/data/opt-in-points');
assert.equal(optIn.status, 403);
assert.equal(optIn.body.error, 'operator-opt-in');
assert.equal(calls, 0);
});
test('a keyed entry gets the operator key server-side and refuses forwarded requests', async () => {
const calls = [];
const request = install({
env: { FIRMS_MAP_KEY: 'k3y' },
fetchImpl: async (url) => {
calls.push(new URL(url));
return new Response(collection([point('Fire')]));
},
});
const forwarded = await request('/data/keyed-points', {
'x-forwarded-for': '203.0.113.9',
});
assert.equal(forwarded.status, 403);
assert.equal(calls.length, 0);
const res = await request('/data/keyed-points');
assert.equal(res.status, 200);
assert.equal(calls[0].searchParams.get('MAP_KEY'), 'k3y');
assert.doesNotMatch(res.raw, /k3y/);
});
test('an oversized or failing upstream answers 502 without its text, then serves stale', async () => {
let clock = 0;
let mode = 'ok';
const request = install({
now: () => clock,
fetchImpl: async () => {
if (mode === 'big') return new Response('x'.repeat(8192));
if (mode === 'fail')
return new Response('secret upstream text', { status: 500 });
return new Response(collection([point('A')]));
},
});
mode = 'big';
const big = await request('/data/proxied-points');
assert.equal(big.status, 502);
assert.doesNotMatch(big.raw, /x{20}/);
mode = 'ok';
assert.equal((await request('/data/proxied-points')).status, 200);
clock = 10 * 3600 * 1000;
mode = 'fail';
const stale = await request('/data/proxied-points');
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.headers['X-Data-Stale'], 'true');
assert.doesNotMatch(stale.raw, /secret upstream text/);
});
test('cross-site browser requests are refused before any upstream request', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return new Response(collection([]));
},
});
const res = await request('/data/proxied-points', {
host: 'localhost:5173',
origin: 'https://evil.example',
'sec-fetch-site': 'cross-site',
});
assert.equal(res.status, 403);
assert.equal(calls, 0);
});
test('tiles: bounded z/x/y, image types only, cached', async () => {
const calls = [];
const request = install({
fetchImpl: async (url) => {
calls.push(url);
if (url.includes('/3/'))
return new Response('<html>', {
headers: { 'content-type': 'text/html' },
});
return new Response(new Uint8Array([137, 80, 78, 71]), {
headers: { 'content-type': 'image/png' },
});
},
});
assert.equal((await request('/tile/proxied-tiles/1/0/0')).status, 400);
assert.equal((await request('/tile/proxied-tiles/2/4/0')).status, 400);
assert.equal((await request('/tile/proxied-tiles/2/a/0')).status, 400);
const ok = await request('/tile/proxied-tiles/2/1/3');
assert.equal(ok.status, 200);
assert.equal(ok.headers['Content-Type'], 'image/png');
assert.equal(calls[0], 'https://tiles.example.org/2/1/3.png');
await request('/tile/proxied-tiles/2/1/3');
assert.equal(calls.length, 1);
assert.equal((await request('/tile/proxied-tiles/3/1/1')).status, 502);
});
test('tile paths are parsed against the zoom range', () => {
const source = { minZoom: 0, maxZoom: 3 };
assert.deepEqual(parseLibraryTilePath(['3', '7', '7'], source), {
z: 3,
x: 7,
y: 7,
});
assert.equal(parseLibraryTilePath(['3', '8', '0'], source), null);
assert.equal(parseLibraryTilePath(['4', '0', '0'], source), null);
assert.equal(parseLibraryTilePath(['1', '0'], source), null);
});
test('only GET is served', async () => {
const plugin = libraryProxy({ entries, env: {} });
const res = {
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end() {},
};
await plugin.handler({ url: '/catalog', method: 'POST', headers: {} }, res);
assert.equal(res.status, 405);
});
+2
View File
@@ -33,6 +33,7 @@ import { inaturalistProxy } from './inaturalist/observations.js';
import { acartiaProxy } from './acartia.js'; import { acartiaProxy } from './acartia.js';
import { cycloneProxy } from './cyclones.js'; import { cycloneProxy } from './cyclones.js';
import { windProxy } from './wind.js'; import { windProxy } from './wind.js';
import { libraryProxy } from './library.js';
/** /**
* Construct the local provider plugins in their established order. * Construct the local provider plugins in their established order.
@@ -81,6 +82,7 @@ function localProviderPlugins({
mapillaryProxy(), mapillaryProxy(),
firePerimetersProxy(), firePerimetersProxy(),
gnssIntegrityProxy(), gnssIntegrityProxy(),
libraryProxy(),
ghostMapsProxy(), ghostMapsProxy(),
droneAirspaceProxy(), droneAirspaceProxy(),
inaturalistProxy(), inaturalistProxy(),
+1 -30
View File
@@ -64,30 +64,6 @@ function validMessages(messages) {
return !pending.length; return !pending.length;
} }
/**
* A tool schema without the `pattern`s Chat Completions refuses: it answers
* 400 for a whole request whose tool uses Unicode property classes (`\p{L}`),
* as `resolve_area`'s area ids do. Every argument is still checked against
* the action's own contract before the tool runs, so the model loses a hint,
* not a rule. Realtime voice accepts these patterns and keeps them.
*/
export function chatSchema(schema) {
if (Array.isArray(schema)) return schema.map(chatSchema);
if (!object(schema)) return schema;
return Object.fromEntries(
Object.entries(schema)
.filter(
([key, value]) =>
!(
key === 'pattern' &&
typeof value === 'string' &&
/\\[pP]\{/.test(value)
),
)
.map(([key, value]) => [key, chatSchema(value)]),
);
}
export function createOpenAiChatHandler({ export function createOpenAiChatHandler({
annotationGuidance, annotationGuidance,
tools = GEV_REALTIME_TOOLS, tools = GEV_REALTIME_TOOLS,
@@ -150,12 +126,7 @@ export function createOpenAiChatHandler({
tools: offeredTools(tools, { overpass }).map( tools: offeredTools(tools, { overpass }).map(
({ name, description, parameters }) => ({ ({ name, description, parameters }) => ({
type: 'function', type: 'function',
function: { function: { name, description, parameters, strict: false },
name,
description,
parameters: chatSchema(parameters),
strict: false,
},
}), }),
), ),
}, },
+2 -2
View File
@@ -1,7 +1,7 @@
import { resolveCatalogSources } from './sourceComposition.js'; import { resolveCatalogSources } from './sourceComposition.js';
import { createWeatherClock } from '../layers/weather/clock.js'; import { createWeatherClock } from '../layers/weather/clock.js';
import { createWeatherLayer } from '../layers/weather/index.js'; import { createWeatherLayer } from '../layers/weather/index.js';
import { createApplicationCyclones } from './layers/cyclones.js'; import { createCyclonesLayer } from '../layers/cyclones/index.js';
import { createWindLayer } from '../layers/wind/index.js'; import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js'; import { createLayerCatalog } from './catalog.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
@@ -182,7 +182,7 @@ export function createApplicationCatalog({
id: 'weather-lightning', id: 'weather-lightning',
clock: weatherClock, clock: weatherClock,
}), }),
createApplicationCyclones({ feed: sources.cyclones }), createCyclonesLayer({ feed: sources.cyclones }),
...createOsmOverlayLayers(), ...createOsmOverlayLayers(),
...createInfrastructureLayers(localGeoJsonServices), ...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }), createApplicationCables({ source: sources.cables }),
+1
View File
@@ -18,6 +18,7 @@ export class LayerPresentation {
this.pendingVisible = false; this.pendingVisible = false;
this._unsubscribe = manager.subscribeActivity((change) => { this._unsubscribe = manager.subscribeActivity((change) => {
if (change.type === 'status') this.refresh(); if (change.type === 'status') this.refresh();
else if (change.type === 'registrations') this._panel?.rerender();
else if (change.type === 'destroy-all') this.destroy(); else if (change.type === 'destroy-all') this.destroy();
else { else {
const reason = const reason =
-10
View File
@@ -1,10 +0,0 @@
import { createCyclonesLayer } from '../../layers/cyclones/index.js';
import * as context from '../../data/contextStore.js';
/** Wire cyclone selection to the application shared context. */
export function createApplicationCyclones(options) {
return createCyclonesLayer({
context: typeof window !== 'undefined' ? context : null,
...options,
});
}
+39
View File
@@ -6,6 +6,10 @@ import { useDisplayStore } from '../ui/stores/display.js';
import { applicationPinia } from '../ui/vueRuntime.js'; import { applicationPinia } from '../ui/vueRuntime.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { createDataLibrary } from '../library/controller.js';
import { flyToLibraryExtent } from '../library/camera.js';
import { mountLibraryBrowser } from '../ui/libraryBrowser.js';
import { localGeoJsonServices } from './localGeojsonServices.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installViews, isEmbeddedInline } from './embed.js'; import { installViews, isEmbeddedInline } from './embed.js';
import { import {
@@ -100,6 +104,41 @@ export function createApplicationTools({
connect: connectDisplay, connect: connectDisplay,
}); });
defer(() => geoFileImport?.destroy()); defer(() => geoFileImport?.destroy());
// DATA ▸ Library: entries register their layers only when a viewer adds
// them, after the core catalog is sealed. The browser dialog and the
// controller share the application lifetime.
const dataLibrary = createDataLibrary({
dataManager,
services: localGeoJsonServices,
getTileset: () => tileset,
mapStackController,
shareLink: styleManager.shareLinkManager,
requestRender: governorRequestRender,
// Zoom to layer: an immediate navigation, so Cockpit can refuse it and
// the current follow owner is released first (as for imported files).
zoomTo: (extent) =>
styleManager.runImmediateNavigation('layer', () =>
flyToLibraryExtent(viewer, extent),
) !== false,
});
defer(() => dataLibrary.destroy());
const libraryTrigger = document.getElementById('data-library-browse');
const libraryBrowser = mountLibraryBrowser({
library: dataLibrary,
trigger: libraryTrigger,
});
defer(() => libraryBrowser.destroy());
if (libraryTrigger) libraryTrigger.hidden = false;
dataLibrary.start({ signal }).catch((error) => {
if (!signal?.aborted) console.warn('[Library] start failed:', error);
});
// The live gate's handle (scripts/qa-data-library.mjs).
const libraryHandle = { library: dataLibrary, browser: libraryBrowser };
window.__gevDataLibrary = libraryHandle;
defer(() => {
if (window.__gevDataLibrary === libraryHandle)
delete window.__gevDataLibrary;
});
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the // DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
// panel lives on the right rail, so both belong to the application // panel lives on the right rail, so both belong to the application
// lifetime. The tileset lets the layer drape while the globe is hidden. // lifetime. The tileset lets the layer drape while the globe is hidden.
-2
View File
@@ -67,8 +67,6 @@ export const _extractPickedCameraIdForTest =
export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest; export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest;
export const focusCctvRecord = layer.focusCctvRecord; export const focusCctvRecord = layer.focusCctvRecord;
export const maybeAutoHop = layer.maybeAutoHop; export const maybeAutoHop = layer.maybeAutoHop;
export const openingCameraId = layer.openingCameraId;
export const reopenNearestCamera = layer.reopenNearestCamera;
export const cctvCycleIndex = layer.cctvCycleIndex; export const cctvCycleIndex = layer.cctvCycleIndex;
export { export {
CCTV_CALIBRATION_STORAGE_KEY_V1, CCTV_CALIBRATION_STORAGE_KEY_V1,
-116
View File
@@ -61,8 +61,6 @@ import cctvLayer, {
materializeCctvActiveCoverageEntities, materializeCctvActiveCoverageEntities,
materializeCctvVisibleCoverageEntities, materializeCctvVisibleCoverageEntities,
maybeAutoHop, maybeAutoHop,
openingCameraId,
reopenNearestCamera,
prioritizeActiveCctvGeometryRecord, prioritizeActiveCctvGeometryRecord,
processCctvGeometryDrainBatch, processCctvGeometryDrainBatch,
processCctvGeometryQueueBatch, processCctvGeometryQueueBatch,
@@ -1298,120 +1296,6 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
} }
}); });
test('CCTV opens on the camera nearest the viewer, not the first in the catalog', () => {
// QA 2026-11-05: at King's Cross, turning CCTV on showed a Sydney camera,
// the first catalog record, instead of the TfL camera 0.3 km away.
const viewer = makeDeselectViewer();
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
-0.1209,
51.5295,
187,
);
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
-0.1209,
51.5295,
187,
);
const sydney = makeDeselectRecord('au-sydney');
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
const london = makeDeselectRecord('gb-kings-cross');
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
try {
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: null,
enabled: false,
});
assert.equal(openingCameraId(), 'gb-kings-cross');
_setCctvCoverageStateForTest({
viewer: null,
records: [sydney, london],
activeCameraId: null,
enabled: false,
});
assert.equal(
openingCameraId(),
'au-sydney',
'with no viewer position the first record is the fallback',
);
_setCctvCoverageStateForTest({ viewer, records: [], enabled: false });
assert.equal(openingCameraId(), null);
} finally {
_setCctvCoverageStateForTest({ enabled: false });
}
});
test('the opening camera follows a shared view that lands after CCTV turned on, until someone picks one', () => {
// A share link turns CCTV on before its flight lands, so enable() picks
// from the startup view; the flight's settled move re-picks near London.
const viewer = makeDeselectViewer();
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
-0.1209,
51.5295,
187,
);
const sydney = makeDeselectRecord('au-sydney');
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
const london = makeDeselectRecord('gb-kings-cross');
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
for (const record of [sydney, london])
Object.assign(record, {
groundResolved: {},
groundSamples: {},
groundPrior: 0,
frustumGeometry: null,
frustumPositions: null,
probeClampRangeM: null,
});
_setCctvOverlayHostForTest({
setEntries() {},
setVisible() {},
clearSource() {},
});
// Activation builds the projection canvas and frame image.
const originalDocument = globalThis.document;
const originalImage = globalThis.Image;
globalThis.document = {
createElement: () => ({ getContext: () => null }),
};
globalThis.Image = class {};
try {
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: 'au-sydney',
enabled: true,
openingCameraProvisional: true,
});
assert.equal(reopenNearestCamera(), true);
assert.equal(cctvLayer.getUIState().activeCameraId, 'gb-kings-cross');
assert.equal(reopenNearestCamera(), false, 'already the nearest');
// Someone chooses a camera: settled moves leave it alone.
setActiveCamera('au-sydney');
assert.equal(reopenNearestCamera(), false);
assert.equal(cctvLayer.getUIState().activeCameraId, 'au-sydney');
// A deselect is a choice too.
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: 'au-sydney',
enabled: true,
openingCameraProvisional: true,
});
deactivateActiveCamera();
assert.equal(reopenNearestCamera(), false);
assert.equal(cctvLayer.getUIState().activeCameraId, null);
} finally {
globalThis.document = originalDocument;
globalThis.Image = originalImage;
_setCctvOverlayHostForTest();
_setCctvCoverageStateForTest({ enabled: false });
}
});
test('CCTV disable clears and hides its shared-host source through the real layer lifecycle', () => { test('CCTV disable clears and hides its shared-host source through the real layer lifecycle', () => {
const calls = []; const calls = [];
_setCctvOverlayHostForTest({ _setCctvOverlayHostForTest({
+3 -3
View File
@@ -12,7 +12,7 @@ import {
CALGARY_IMAGE_ORIGIN, CALGARY_IMAGE_ORIGIN,
CALGARY_MAX_CATALOG_BYTES, CALGARY_MAX_CATALOG_BYTES,
DEFAULT_CALGARY_ROWS_URL, DEFAULT_CALGARY_ROWS_URL,
CCTV_SHARED_SOURCE_SLOTS, DEFAULT_CCTV_MAX_SOURCES,
} from '../../server/providers/cctv/constants.js'; } from '../../server/providers/cctv/constants.js';
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js'; import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
import { allocateSourceCap } from '../../server/providers/cctv/cap.js'; import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
@@ -384,6 +384,6 @@ test('CCTV_CALGARY_ENABLED=0 keeps the lane from being loaded at all', async (t)
} }
}); });
test('the shared catalog slots are not raised to make room for this pack', () => { test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000); assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
}); });
-65
View File
@@ -6,12 +6,9 @@ import {
} from '../../server/providers/cctv/cap.js'; } from '../../server/providers/cctv/cap.js';
import { import {
CCTV_MAX_SOURCES_CEILING, CCTV_MAX_SOURCES_CEILING,
CCTV_SHARED_SOURCE_SLOTS,
CCTV_SOURCE_CACHE_MS, CCTV_SOURCE_CACHE_MS,
DEFAULT_CCTV_MAX_SOURCES, DEFAULT_CCTV_MAX_SOURCES,
WORLD_CCTV_SOURCE_COUNT,
} from '../../server/providers/cctv/constants.js'; } from '../../server/providers/cctv/constants.js';
import { QUEBEC_CAMERA_SOURCE_COUNT } from '../../server/providers/cctv/quebec.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
const pack = (name, count, prefix = name) => ({ const pack = (name, count, prefix = name) => ({
@@ -102,68 +99,6 @@ test('duplicate ids resolve last-pack-wins and count against the winning pack',
]); ]);
}); });
/** Shared packs plus two reserved ones, shaped like the default catalog. */
const reservedFixture = () => [
...Array.from({ length: 20 }, (_, i) => pack(`shared${i}`, 250)),
{ ...pack('quebec', 1120), reservedCount: 1120 },
{ ...pack('world', 2230), reservedCount: 2230 },
];
const keptByName = (packs) =>
Object.fromEntries(packs.map(({ name, kept }) => [name, kept]));
test('the default cap is the shared floor plus the reserved Québec and World packs', () => {
assert.equal(
DEFAULT_CCTV_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS +
QUEBEC_CAMERA_SOURCE_COUNT +
WORLD_CCTV_SOURCE_COUNT,
);
});
test('at the default cap reserved packs are whole and shared packs keep the floor', () => {
const { sources, packs } = allocateSourceCap(
reservedFixture(),
DEFAULT_CCTV_MAX_SOURCES,
);
const kept = keptByName(packs);
assert.equal(sources.length, DEFAULT_CCTV_MAX_SOURCES);
assert.equal(kept.quebec, 1120);
assert.equal(kept.world, 2230);
for (let i = 0; i < 20; i++) assert.equal(kept[`shared${i}`], 200);
assert.match(
sources[0].id,
/^shared0-/,
'the first camera stays in a shared pack',
);
});
test('a cap at the shared floor reserves nothing and thins every pack evenly', () => {
const { packs } = allocateSourceCap(reservedFixture(), 4000);
const kept = keptByName(packs);
// 22 lanes share 4,000 round-robin: nobody is wiped out by the reservation.
for (const count of Object.values(kept))
assert.ok(count >= 181 && count <= 182);
});
test('a small cap (dev-secure 48) still reaches the shared packs first', () => {
const { sources, packs } = allocateSourceCap(reservedFixture(), 48);
const kept = keptByName(packs);
assert.equal(sources.length, 48);
for (let i = 0; i < 20; i++) assert.ok(kept[`shared${i}`] >= 2);
assert.match(sources[0].id, /^shared0-/);
});
test('reservations fill only the room above the floor, round-robin between reserved packs', () => {
const { packs } = allocateSourceCap(reservedFixture(), 4100);
const kept = keptByName(packs);
const shared = Object.entries(kept)
.filter(([name]) => name.startsWith('shared'))
.reduce((sum, [, count]) => sum + count, 0);
// 100 reserved slots split between Québec and World, then 4,000 round-robin.
assert.ok(shared >= 4000 - 2 * 182, `shared packs kept ${shared}`);
assert.equal(shared + kept.quebec + kept.world, 4100);
});
test('resolveCatalogCap bounds the env value and falls back to the default', () => { test('resolveCatalogCap bounds the env value and falls back to the default', () => {
assert.equal(resolveCatalogCap(undefined), DEFAULT_CCTV_MAX_SOURCES); assert.equal(resolveCatalogCap(undefined), DEFAULT_CCTV_MAX_SOURCES);
assert.equal(resolveCatalogCap('nonsense'), DEFAULT_CCTV_MAX_SOURCES); assert.equal(resolveCatalogCap('nonsense'), DEFAULT_CCTV_MAX_SOURCES);
+3 -8
View File
@@ -14,8 +14,6 @@ import {
DEFAULT_ONTARIO_MAX_SOURCES, DEFAULT_ONTARIO_MAX_SOURCES,
DEFAULT_FINTRAFFIC_MAX_SOURCES, DEFAULT_FINTRAFFIC_MAX_SOURCES,
DEFAULT_TXDOT_MAX_SOURCES, DEFAULT_TXDOT_MAX_SOURCES,
DEFAULT_OTTAWA_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS,
DEFAULT_TALLINN_MAX_SOURCES, DEFAULT_TALLINN_MAX_SOURCES,
DEFAULT_TARKTEE_MAX_SOURCES, DEFAULT_TARKTEE_MAX_SOURCES,
DEFAULT_NSW_MAX_SOURCES, DEFAULT_NSW_MAX_SOURCES,
@@ -224,7 +222,7 @@ test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the r
); );
}); });
test('default caps of the original packs fit inside the shared catalog slots', () => { test('default caps of the original packs fit inside the default catalog cap', () => {
// Every default pack at its own cap (Warendorf ships 1 curated camera). // Every default pack at its own cap (Warendorf ships 1 curated camera).
const packs = const packs =
DEFAULT_AUSTIN_MAX_SOURCES + DEFAULT_AUSTIN_MAX_SOURCES +
@@ -233,18 +231,15 @@ test('default caps of the original packs fit inside the shared catalog slots', (
DEFAULT_ONTARIO_MAX_SOURCES + DEFAULT_ONTARIO_MAX_SOURCES +
DEFAULT_FINTRAFFIC_MAX_SOURCES + DEFAULT_FINTRAFFIC_MAX_SOURCES +
DEFAULT_DRIVEBC_MAX_SOURCES + DEFAULT_DRIVEBC_MAX_SOURCES +
DEFAULT_OTTAWA_MAX_SOURCES +
DEFAULT_TXDOT_MAX_SOURCES + DEFAULT_TXDOT_MAX_SOURCES +
DEFAULT_TALLINN_MAX_SOURCES + DEFAULT_TALLINN_MAX_SOURCES +
DEFAULT_TARKTEE_MAX_SOURCES + DEFAULT_TARKTEE_MAX_SOURCES +
DEFAULT_NSW_MAX_SOURCES + DEFAULT_NSW_MAX_SOURCES +
DEFAULT_CATALONIA_MAX_SOURCES + DEFAULT_CATALONIA_MAX_SOURCES +
1; 1;
// The reserved Québec and World packs sit above the shared floor, so the
// shared packs must fit the floor itself, not the whole default cap.
assert.ok( assert.ok(
packs <= CCTV_SHARED_SOURCE_SLOTS, packs <= DEFAULT_CCTV_MAX_SOURCES,
`default packs (${packs}) would be thinned by the shared slots (${CCTV_SHARED_SOURCE_SLOTS})`, `default packs (${packs}) would be thinned by the catalog cap (${DEFAULT_CCTV_MAX_SOURCES})`,
); );
assert.ok( assert.ok(
DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING, DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING,
+4 -4
View File
@@ -10,7 +10,7 @@ import {
parseHkCameraLocationsXml, parseHkCameraLocationsXml,
} from '../../server/providers/cctv/sources.js'; } from '../../server/providers/cctv/sources.js';
import { import {
CCTV_SHARED_SOURCE_SLOTS, DEFAULT_CCTV_MAX_SOURCES,
DEFAULT_HK_CAMERAS_URL, DEFAULT_HK_CAMERAS_URL,
DEFAULT_HK_MAX_SOURCES, DEFAULT_HK_MAX_SOURCES,
HK_ANCHORS, HK_ANCHORS,
@@ -396,8 +396,8 @@ test('CCTV_HK_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
} }
}); });
test('the shared catalog slots are not raised to make room for this pack', () => { test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000); assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
}); });
test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => { test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => {
@@ -406,7 +406,7 @@ test('the HK pack default sits above the full TD catalog and anchors the New Ter
'default cap should keep nearly all published TD cameras', 'default cap should keep nearly all published TD cameras',
); );
assert.ok( assert.ok(
DEFAULT_HK_MAX_SOURCES <= CCTV_SHARED_SOURCE_SLOTS, DEFAULT_HK_MAX_SOURCES <= DEFAULT_CCTV_MAX_SOURCES,
'a single pack must not outgrow the catalog-wide ceiling alone', 'a single pack must not outgrow the catalog-wide ceiling alone',
); );
const places = HK_ANCHORS.map( const places = HK_ANCHORS.map(
-71
View File
@@ -10,77 +10,6 @@ const playlist =
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:12\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n'; '#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:12\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n';
const base = 'https://camera.example/live/list.m3u8'; const base = 'https://camera.example/live/list.m3u8';
test('high-resolution HLS segments play while the retained session cache stays bounded', async (t) => {
// Giroc publishes roughly ten-second TS segments; one measured 6,437,120
// bytes. The former 4 MiB download cap rejected it before playback began.
const segmentBytes = 6_437_120;
const master =
'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=4570000\ntracks-v1a1/mono.m3u8\n';
const media =
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:100\n' +
[0, 1, 2, 3, 4].map((i) => `#EXTINF:10.272,\n${i}.ts\n`).join('');
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 100000 },
fetchImpl: async (url) => {
if (url === base) return new Response(master);
if (url === 'https://camera.example/live/tracks-v1a1/mono.m3u8')
return new Response(media);
const match =
/^https:\/\/camera\.example\/live\/tracks-v1a1\/([0-4])\.ts$/.exec(url);
if (!match) throw new Error('Unexpected playlist reference');
// No Content-Length: the actual streamed bytes must also be accepted.
return new Response(Buffer.alloc(segmentBytes, Number(match[1])));
},
});
t.after(() => manager.shutdown());
const entry = await manager.ensure('giroc', base);
await entry.polling;
const result = await manager.buildPlaylist(entry, 'giroc');
assert.ok(result, 'large valid segments should produce a playable playlist');
assert.match(result, /#EXT-X-MEDIA-SEQUENCE:2\n/);
assert.equal(manager.getSegment('giroc', entry.token, 0), null);
const newest = manager.getSegment('giroc', entry.token, 4);
assert.equal(newest.length, segmentBytes);
assert.equal(newest[0], 4);
assert.deepEqual(manager.stats(), { sessions: 1, bytes: 19_311_360 });
});
test('HLS downloads still reject declared and chunked bodies above 8 MiB', async () => {
for (const declared of [true, false]) {
let chunks = 0;
let cancelled = false;
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: HLS_LIMITS.segmentBytes,
fetchImpl: async () =>
new Response(
declared
? 'small body'
: new ReadableStream({
pull(controller) {
// Each chunk fits on its own; only their cumulative size
// crosses the cap. Leave it open to verify cancellation.
controller.enqueue(
Buffer.alloc(4 * 1024 * 1024 + (++chunks === 2 ? 1 : 0)),
);
},
cancel() {
cancelled = true;
},
}),
{
headers: declared
? { 'content-length': String(8 * 1024 * 1024 + 1) }
: {},
},
),
}),
/HLS response too large/,
);
if (!declared) assert.equal(cancelled, true);
}
});
test('playlist parser uses sequence, refuses escaping and unsupported references', () => { test('playlist parser uses sequence, refuses escaping and unsupported references', () => {
assert.deepEqual( assert.deepEqual(
parseHlsMedia(playlist, base).map((s) => s.seq), parseHlsMedia(playlist, base).map((s) => s.seq),
+3 -3
View File
@@ -7,7 +7,7 @@ import {
loadHoustonSourcesFromOpenData, loadHoustonSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js'; } from '../../server/providers/cctv/sources.js';
import { import {
CCTV_SHARED_SOURCE_SLOTS, DEFAULT_CCTV_MAX_SOURCES,
DEFAULT_HOUSTON_MAX_SOURCES, DEFAULT_HOUSTON_MAX_SOURCES,
HOUSTON_CAMERAS_URL, HOUSTON_CAMERAS_URL,
HOUSTON_GROUND_ELEVATION_M, HOUSTON_GROUND_ELEVATION_M,
@@ -387,8 +387,8 @@ test('Houston stays unloaded unless CCTV_HOUSTON_ENABLED=1', async (t) => {
} }
}); });
test('the shared catalog slots are not raised to make room for this pack', () => { test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000); assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
assert.equal(DEFAULT_HOUSTON_MAX_SOURCES, 250); assert.equal(DEFAULT_HOUSTON_MAX_SOURCES, 250);
}); });
-429
View File
@@ -1,429 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import {
DEFAULT_ICELAND_MAX_SOURCES,
ICELAND_CAMERAS_URL,
ICELAND_IMAGE_ORIGIN,
ICELAND_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
import { isLikelyIcelandCoordinate } from '../../server/providers/cctv/normalize.js';
import {
icelandCameraToSource,
icelandHeadingFromDescription,
loadIcelandSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** One row shaped like the live `vefmyndavelar2014_1` payload. */
const row = (over = {}) => ({
Maelist_nr: 7001,
Myndavel: 'Hellisheiði',
Vegheiti: 'Hringvegur',
NrVegur: '1',
Skyring: 'Hellisheiði séð til vesturs',
Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`,
PntX: 395000,
PntY: 390000,
Breidd: 64.0136,
Lengd: -21.4,
...over,
});
/** A live response body plus a flag that flips when the stream is cancelled. */
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('['));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
const json = (value) =>
new Response(JSON.stringify(value), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
test('an Iceland row maps to a source on the pinned frame directory', () => {
const source = icelandCameraToSource(row());
assert.equal(source.id, 'is-vegagerdin-hellisheidi_1');
assert.equal(source.name, 'Hellisheiði séð til vesturs');
assert.equal(source.city, 'Iceland');
assert.equal(source.cityId, 'iceland');
assert.equal(source.provider, 'Vegagerðin (IRCA)');
assert.equal(source.lat, 64.0136);
assert.equal(source.lon, -21.4);
assert.equal(source.headingDeg, 270);
assert.equal(source.headingConfidence, 'high');
assert.equal(source.pitchDeg, -24);
assert.equal(source.fovDeg, 56);
assert.equal(source.rangeM, 210);
assert.equal(source.mountHeightM, 10);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'iceland-vegagerdin');
assert.equal(source.code, 'HELLISHEIÐI');
assert.match(source.license, /CC BY 4\.0/);
assert.ok(source.url.startsWith(ICELAND_IMAGE_ORIGIN));
assert.equal(source.snapshotUrl, source.url);
});
test('a view that states no facing takes the low-confidence id-hash pose', () => {
const source = icelandCameraToSource(
row({
Skyring: 'Hellisheiði séð niður á veg',
Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg`,
}),
);
assert.equal(source.headingConfidence, 'low');
assert.equal(source.headingDeg % 22.5, 0);
assert.equal(
source.headingDeg,
icelandCameraToSource(
row({ Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg` }),
).headingDeg,
'the fallback depends on the id, not on the text',
);
});
test('the label collapses the feed’s doubled spaces and repeated words', () => {
assert.equal(
icelandCameraToSource(
row({ Skyring: 'Strandarheiði séð niður á veg til til austurs' }),
).name,
'Strandarheiði séð niður á veg til austurs',
);
const label = (Skyring) => icelandCameraToSource(row({ Skyring })).name;
// Repeats of words with Icelandic letters collapse too.
assert.equal(
label('Hellisheiði séð séð til austurs'),
'Hellisheiði séð til austurs',
);
assert.equal(label('Brú á á Jökulsá'), 'Brú á Jökulsá');
// A word ending in a letter the next word starts with is not a repeat.
for (const text of [
'Þverá Árnessýslu',
'brú Úlfarsá',
'Vesturlandsvegur við Elliðaá Ártúnsbrekku',
])
assert.equal(label(text), text);
});
test('a row with no label, station or station number is named by its frame', () => {
const source = icelandCameraToSource(
row({ Skyring: '', Myndavel: '', Maelist_nr: undefined }),
);
assert.equal(source.name, 'Vegagerðin hellisheidi_1');
});
test('only an explicit facing sets a heading', () => {
const cases = [
['Hellisheiði séð til vesturs', 270],
['Holtavörðuheiði til norðurs', 0],
['Klettsháls í suður', 180],
['Arnarneshæð séð austur', 90],
['Bústaðabrú séð norður Kringlumýrarbraut', 0],
['Staður séð til norðausturs', 45],
['Staður séð til suðausturs', 135],
['Staður séð í suðvestur', 225],
['Staður séð til norðvesturs', 315],
['STAÐUR SÉÐ TIL VESTURS', 270],
['Brú séð til austurs og Selfoss', 90], // "og" then a place, not a facing
['Staður séð til austurs og Vesturlandsvegur', 90],
];
for (const [text, heading] of cases) {
assert.equal(icelandHeadingFromDescription(text), heading, text);
}
for (const text of [
'',
null,
'Gautsdalur niður á veg',
'Miklabraut austur', // a road direction, not a facing
'Biskupsháls - norður',
'Hringtorg norðan Selfoss',
'Norðurárdalur niður á veg', // a place name that contains a cardinal
'Staður séð til suðurlands', // a longer word, not "suður"
'Staður séð til norðurs og til suðurs', // two facings: ambiguous
'Staður séð til austurs og vesturs', // the second facing shares "til"
'Staður séð í norður og suður',
'Staður séð í norður og í suður',
'Norðfjarðargöng séð til Eskifjarðar',
]) {
assert.ok(Number.isNaN(icelandHeadingFromDescription(text)), String(text));
}
});
test('rows off the pinned directory, or with unusable frames or places, are dropped', () => {
const bad = [
{ Slod: 'https://example.com/vgdata/vefmyndavelar/a_1.jpg' },
{ Slod: 'http://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.jpg' },
{ Slod: 'https://user:[email protected]/vgdata/vefmyndavelar/a_1.jpg' },
{ Slod: 'https://www.vegagerdin.is:8443/vgdata/vefmyndavelar/a_1.jpg' },
{ Slod: 'https://www.vegagerdin.is/vgdata/other/a_1.jpg' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/../x/a_1.jpg' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a%2Fb_1.jpg' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.php' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a b_1.jpg' },
{ Slod: '' },
{ Slod: null },
{ Breidd: 0, Lengd: 0 },
{ Breidd: null, Lengd: null },
{ Breidd: '64.01', Lengd: '-21.4' },
{ Breidd: '64.01' }, // each coordinate must be a number on its own
{ Lengd: '-21.4' },
{ Breidd: 59.9, Lengd: 10.7 }, // Oslo
{ Breidd: 64.01, Lengd: 21.4 }, // wrong hemisphere
];
for (const over of bad) {
assert.equal(icelandCameraToSource(row(over)), null, JSON.stringify(over));
}
assert.equal(icelandCameraToSource(null), null);
assert.equal(icelandCameraToSource({}), null);
assert.equal(isLikelyIcelandCoordinate(64.1466, -21.9426), true);
assert.equal(isLikelyIcelandCoordinate(66.3, -14.0), true);
});
test('the Iceland loader reads the list with redirects refused and keeps the nearest cameras', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: '8' });
const rows = Array.from({ length: 12 }, (_, i) =>
row({
Maelist_nr: 8000 + i,
Slod: `${ICELAND_IMAGE_ORIGIN}far_${i}.jpg`,
Skyring: `Far ${i}`,
Breidd: 66.2,
Lengd: -15.9 + i * 0.01,
}),
);
// Reykjavík cameras come last in the feed but first in the priority order.
// A frame the feed repeats keeps its last row, as the other packs do.
rows.push(row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, Skyring: 'Old' }));
rows.push(
row({
Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`,
Skyring: 'Kringlumýrarbraut',
Breidd: 64.1466,
Lengd: -21.9426,
}),
);
const seen = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return json(rows);
});
const sources = await loadIcelandSourcesFromOpenData();
assert.deepEqual(seen, [[ICELAND_CAMERAS_URL, 'manual']]);
assert.equal(sources.length, 8);
assert.equal(sources[0].id, 'is-vegagerdin-rvk_1');
assert.equal(sources[0].name, 'Kringlumýrarbraut');
assert.equal(sources[0].lat, 64.1466);
assert.equal(
new Set(sources.map((s) => s.id)).size,
sources.length,
'a frame the feed repeats collapses to one camera',
);
assert.equal(sources.available, 13);
});
test('the default Iceland cap is DEFAULT_ICELAND_MAX_SOURCES', async (t) => {
t.mock.method(console, 'log', () => {});
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined });
const rows = Array.from(
{ length: DEFAULT_ICELAND_MAX_SOURCES + 40 },
(_, i) => row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }),
);
t.mock.method(globalThis, 'fetch', async () => json(rows));
assert.equal(
(await loadIcelandSourcesFromOpenData()).length,
DEFAULT_ICELAND_MAX_SOURCES,
);
});
test('CCTV_ICELAND_MAX_SOURCES is clamped to 8..600 and ignored when not a number', async (t) => {
t.mock.method(console, 'log', () => {});
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined });
const rows = Array.from({ length: 650 }, (_, i) =>
row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }),
);
t.mock.method(globalThis, 'fetch', async () => json(rows));
for (const [value, expected] of [
['0', 8],
['3', 8],
['1000', 600],
['abc', DEFAULT_ICELAND_MAX_SOURCES],
]) {
process.env.CCTV_ICELAND_MAX_SOURCES = value;
assert.equal(
(await loadIcelandSourcesFromOpenData()).length,
expected,
value,
);
}
});
test('a redirect on the Iceland frame host stays inside vefmyndavelar/', async () => {
const frame = `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`;
const redirectTo = (location) => async (url) =>
url === frame
? new Response(null, { status: 302, headers: { location } })
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
for (const location of [
'/vgdata/other/x.jpg',
'/vgdata/vefmyndavelar/../x.jpg',
'/',
])
assert.equal(
await fetchWithinHost(frame, {}, redirectTo(location)),
null,
location,
);
const followed = await fetchWithinHost(
frame,
{},
redirectTo('/vgdata/vefmyndavelar/hellisheidi_1b.jpg'),
);
assert.equal(await followed.text(), 'frame');
});
test('the Iceland loader fails soft on HTTP errors, bad payloads and network errors', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(
globalThis,
'fetch',
async () => new Response('x', { status: 503 }),
);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => json({ rows: [] }));
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.method(
globalThis,
'fetch',
async () => new Response('<html>', { status: 200 }),
);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
});
test('the Iceland catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
const redirected = streamingResponse({
status: 302,
headers: {
location: 'http://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1',
},
});
t.mock.method(globalThis, 'fetch', async () => redirected.response);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
const failed = streamingResponse({ status: 500 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the error body is cancelled');
const valid = JSON.stringify([row()]);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
return new Response(valid, {
headers: { 'content-length': String(ICELAND_MAX_CATALOG_BYTES + 1) },
});
});
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const padded = valid + ' '.repeat(ICELAND_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(padded));
controller.close();
},
}),
);
});
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
});
test('CCTV catalog merges Iceland cameras and CCTV_ICELAND_ENABLED=0 skips the request', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const sourceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'gev-cctv-catalog-iceland-'),
);
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
withEnv(t, {
CCTV_SOURCES_FILE: undefined,
CCTV_SOURCES_JSON: undefined,
CCTV_FORCE_AUSTIN: undefined,
CCTV_PREFER_AUSTIN: undefined,
CCTV_MAX_SOURCES: undefined,
CCTV_CALTRANS_DISTRICTS: undefined,
CCTV_TFL_ENABLED: undefined,
CCTV_ICELAND_ENABLED: undefined,
CCTV_ICELAND_MAX_SOURCES: undefined,
});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push([String(url), init.redirect]);
if (String(url) !== ICELAND_CAMERAS_URL) {
return new Response('unavailable', { status: 503 });
}
return json([row()]);
});
const sources = await createCctvCatalog({ sourceRoot })();
assert.deepEqual(
sources.map((source) => source.id),
['is-vegagerdin-hellisheidi_1'],
);
process.env.CCTV_ICELAND_ENABLED = '0';
requested.length = 0;
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
assert.ok(requested.length > 0, 'the other live packs still load');
assert.equal(
requested.some(([url]) => url === ICELAND_CAMERAS_URL),
false,
'a disabled pack makes no request',
);
});
+3 -3
View File
@@ -8,7 +8,7 @@ import {
nycCameraToSource, nycCameraToSource,
} from '../../server/providers/cctv/sources.js'; } from '../../server/providers/cctv/sources.js';
import { import {
CCTV_SHARED_SOURCE_SLOTS, DEFAULT_CCTV_MAX_SOURCES,
DEFAULT_NYC_MAX_SOURCES, DEFAULT_NYC_MAX_SOURCES,
NYC_CAMERAS_URL, NYC_CAMERAS_URL,
NYC_GROUND_ELEVATION_M, NYC_GROUND_ELEVATION_M,
@@ -375,8 +375,8 @@ for (const [label, flag] of [
}); });
} }
test('the shared catalog slots are not raised to make room for this pack', () => { test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000); assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250); assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
}); });
-255
View File
@@ -1,255 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import {
OTTAWA_CAMERAS_URL,
OTTAWA_IMAGE_ORIGIN,
} from '../../server/providers/cctv/constants.js';
import {
fallbackHeadingFromId,
pinnedCameraUrl,
} from '../../server/providers/cctv/normalize.js';
import {
_resetOttawaCertificateWarningForTest,
loadOttawaSourcesFromOpenData,
ottawaAuthorizedFrameUrl,
} from '../../server/providers/cctv/ottawa.js';
const CERT = { OTTAWA_CAMERA_CERTIFICATE: 'CERT123' };
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** Silence the loader's progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/** One documented `/map/service/camera` row carrying the fields the loader reads. */
function ottawaRow(number, lat, lon, overrides = {}) {
return {
id: number + 10000,
camera_number: number,
name: `Camera ${number}`,
name_french: `Caméra ${number}`,
cameraOwner: 'CITY',
latitude: lat,
longitude: lon,
...overrides,
};
}
/** The feed's envelope around its rows. */
const ottawaPayload = (rows) => Response.json({ cameras: rows });
test('without OTTAWA_CAMERA_CERTIFICATE the Ottawa pack is skipped with no request, logged once', async (t) => {
_resetOttawaCertificateWarningForTest();
const lines = [];
t.mock.method(console, 'warn', (line) => lines.push(String(line)));
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
throw new Error('no request expected');
});
assert.deepEqual(await loadOttawaSourcesFromOpenData({}, {}), []);
assert.deepEqual(
await loadOttawaSourcesFromOpenData({}, { OTTAWA_CAMERA_CERTIFICATE: ' ' }),
[],
);
assert.equal(fetchMock.mock.callCount(), 0);
assert.equal(lines.length, 1);
assert.match(lines[0], /OTTAWA_CAMERA_CERTIFICATE/);
});
test('Ottawa frame URLs are signed only inside the opendata camera path', () => {
assert.equal(
ottawaAuthorizedFrameUrl('https://traffic.ottawa.ca/opendata/camera?c=16', CERT),
'https://traffic.ottawa.ca/opendata/camera?c=16&certificate=CERT123&id=gev',
);
assert.equal(
ottawaAuthorizedFrameUrl('https://traffic.ottawa.ca/opendata/camera?c=16', {}),
'',
'no certificate, no signed URL',
);
for (const url of [
'https://traffic.ottawa.ca/opendata/camera?c=16x',
'https://traffic.ottawa.ca/opendata/other?c=16',
'https://traffic.ottawa.ca/map/camera?id=16',
'https://traffic.ottawa.ca.evil.test/opendata/camera?c=16',
'http://traffic.ottawa.ca/opendata/camera?c=16',
])
assert.equal(ottawaAuthorizedFrameUrl(url, CERT), '', url);
});
test('Ottawa loader keeps in-city cameras and builds keyless frame URLs from the camera number', async (t) => {
quiet(t);
withEnv(t, { CCTV_OTTAWA_MAX_SOURCES: undefined });
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return ottawaPayload([
ottawaRow(2025, 45.341343, -75.81467, {
name: '(MTO) Hwy 416 NB ramp to Hwy 417 East',
cameraOwner: 'MTO',
}),
ottawaRow(1, 45.4215, -75.6972, {
name: 'Bank St at Somerset St',
camera_owner: 'CITY',
cameraOwner: undefined,
}),
ottawaRow(1, 45.4216, -75.6973, { name: 'duplicate number' }),
ottawaRow(0, 45.4215, -75.6972),
ottawaRow(-3, 45.4215, -75.6972),
ottawaRow(7, null, -75.7),
ottawaRow(8, 45.4, null),
ottawaRow(9, 43.6532, -79.3832, { description: 'Toronto' }),
ottawaRow(10, 45.9, -75.7),
]);
});
const cameras = await loadOttawaSourcesFromOpenData({}, CERT);
assert.deepEqual(requested, [OTTAWA_CAMERAS_URL]);
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
'ottawa-1',
'ottawa-2025',
]);
const mto = cameras.find((camera) => camera.id === 'ottawa-2025');
const city = cameras.find((camera) => camera.id === 'ottawa-1');
assert.equal(mto.url, 'https://traffic.ottawa.ca/opendata/camera?c=2025');
assert.doesNotMatch(mto.url, /certificate/, 'the catalog URL carries no key');
assert.equal(mto.snapshotUrl, mto.url);
assert.equal(mto.frameRefreshMs, 60000, 'the API allows one fetch a minute');
assert.equal(city.name, 'Bank St at Somerset St');
assert.equal(mto.name, '(MTO) Hwy 416 NB ramp to Hwy 417 East');
assert.equal(mto.city, 'Ottawa');
assert.equal(mto.provider, 'City of Ottawa');
assert.equal(mto.feedType, 'image');
assert.equal(mto.sourceKind, 'ottawa-traffic');
assert.equal(mto.headingConfidence, 'low');
assert.equal(
mto.headingDeg,
fallbackHeadingFromId('ottawa-2025'),
'the feed publishes no bearing, so the id-hash fallback is deterministic',
);
assert.match(mto.license, /City of Ottawa/);
assert.equal(
mto.credit,
'Ministry of Transportation of Ontario (MTO) camera',
'a provincial camera carried on the city feed names its owner',
);
assert.equal(city.credit, '', 'a city camera carries no partner credit');
});
test('Ottawa loader keeps the cameras nearest downtown under its cap', async (t) => {
quiet(t);
withEnv(t, { CCTV_OTTAWA_MAX_SOURCES: '8' });
const rows = [
// Far suburbs come first in payload order, so only distance can drop them.
...[45.55, 45.54, 45.53, 45.52].map((lat, i) =>
ottawaRow(100 + i, lat, -76.1),
),
...[45.42, 45.421, 45.422, 45.423, 45.424, 45.425, 45.426, 45.427].map(
(lat, i) => ottawaRow(200 + i, lat, -75.7),
),
];
t.mock.method(globalThis, 'fetch', async () => ottawaPayload(rows));
const cameras = await loadOttawaSourcesFromOpenData({}, CERT);
assert.equal(cameras.length, 8);
assert.deepEqual(
cameras.map((camera) => camera.id).filter((id) => /^ottawa-10\d$/.test(id)),
[],
'the far-suburb cameras are the ones dropped',
);
});
test('Ottawa loader fails soft on HTTP errors, unexpected payloads and network errors', async (t) => {
quiet(t);
let respond;
t.mock.method(globalThis, 'fetch', (...args) => respond(...args));
for (respond of [
async () => new Response('unavailable', { status: 503 }),
async () => Response.json({ cameras: [] }),
async () => Response.json([ottawaRow(1, 45.4215, -75.6972)]),
async () => {
throw new TypeError('fetch failed');
},
]) {
assert.deepEqual(await loadOttawaSourcesFromOpenData({}, CERT), []);
}
});
test('CCTV catalog merges Ottawa cameras with a certificate and CCTV_OTTAWA_ENABLED=0 skips the request', async (t) => {
quiet(t);
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-ottawa-'));
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
withEnv(t, {
CCTV_SOURCES_FILE: undefined,
CCTV_SOURCES_JSON: undefined,
CCTV_FORCE_AUSTIN: undefined,
CCTV_PREFER_AUSTIN: undefined,
CCTV_MAX_SOURCES: undefined,
CCTV_OTTAWA_ENABLED: undefined,
CCTV_OTTAWA_MAX_SOURCES: undefined,
CCTV_QUEBEC_ENABLED: '0',
CCTV_WORLD_CAMERAS_ENABLED: '0',
OTTAWA_CAMERA_CERTIFICATE: 'CERT123',
});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
// Every other live pack is down; only Ottawa answers.
return String(url) === OTTAWA_CAMERAS_URL
? ottawaPayload([ottawaRow(1, 45.4215, -75.6972)])
: new Response('unavailable', { status: 503 });
});
const sources = await createCctvCatalog({ sourceRoot })();
assert.deepEqual(
sources.map((source) => source.id),
['ottawa-1'],
);
assert.equal(sources[0].url, 'https://traffic.ottawa.ca/opendata/camera?c=1');
process.env.CCTV_OTTAWA_ENABLED = '0';
requested.length = 0;
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
assert.ok(requested.length > 0, 'the other live packs still load');
assert.equal(
requested.includes(OTTAWA_CAMERAS_URL),
false,
'a disabled pack makes no request',
);
});
test('Ottawa frames stay inside the city image directory', () => {
assert.ok(
pinnedCameraUrl(
'https://traffic.ottawa.ca/opendata/camera?c=309',
OTTAWA_IMAGE_ORIGIN,
),
);
for (const url of [
'https://traffic.ottawa.ca.evil.test/opendata/camera?c=309',
'https://traffic.ottawa.ca/map/camera?id=309',
'https://user:[email protected]/opendata/camera?c=309',
'https://traffic.ottawa.ca:8443/opendata/camera?c=309',
'http://traffic.ottawa.ca/opendata/camera?c=309',
])
assert.equal(pinnedCameraUrl(url, OTTAWA_IMAGE_ORIGIN), null, url);
});
-231
View File
@@ -313,234 +313,3 @@ test('CCTV upstream frame fetch accepts only raster image types and sends their
assert.equal(state.cancelled, true, `${contentType}: body cancelled`); assert.equal(state.cancelled, true, `${contentType}: body cancelled`);
} }
}); });
function multipartResponse(contentType, chunks, { onCancel = () => {} } = {}) {
let nextChunk = 0;
const body = new ReadableStream(
{
pull(controller) {
if (nextChunk >= chunks.length) {
controller.close();
return;
}
controller.enqueue(chunks[nextChunk++]);
},
cancel() {
onCancel();
},
},
{ highWaterMark: 0 },
);
return new Response(body, {
status: 200,
headers: { 'Content-Type': contentType },
});
}
function jpegPart(boundary, body, contentType = 'image/jpeg', length = body.length) {
return Buffer.concat([
Buffer.from(
'--' +
boundary +
'\r\nContent-Type: ' +
contentType +
'\r\nContent-Length: ' +
length +
'\r\n\r\n',
),
body,
Buffer.from('\r\n'),
]);
}
test('CCTV upstream frame fetch extracts only the first JPEG from MJPEG', async () => {
const boundary = 'DIGIEVER';
const first = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
const second = Buffer.from([0xff, 0xd8, 0x33, 0x44, 0xff, 0xd9]);
for (const type of [
'multipart/x-mixed-replace; boundary=DIGIEVER',
'multipart/x-mixed-replace; boundary="DIGIEVER"',
]) {
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
maxBytes: 1024,
fetchImpl: async () =>
multipartResponse(
type,
[
jpegPart(boundary, first),
jpegPart(boundary, second),
Buffer.from('--' + boundary + '--\r\n'),
],
{ onCancel: () => { cancelled = true; } },
),
},
);
assert.equal(result?.contentType, 'image/jpeg', type);
assert.deepEqual(result?.body, first, type);
assert.equal(cancelled, true, type);
}
});
test('CCTV upstream frame fetch rejects malformed and non-JPEG MJPEG parts', async () => {
const jpeg = Buffer.from([0xff, 0xd8, 0x01, 0x02, 0xff, 0xd9]);
for (const [type, chunks] of [
['multipart/x-mixed-replace', []],
[
'multipart/x-mixed-replace; boundary=DIGIEVER',
[jpegPart('DIGIEVER', jpeg, 'image/png')],
],
]) {
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
fetchImpl: async () =>
multipartResponse(type, chunks, { onCancel: () => { cancelled = true; } }),
},
);
assert.equal(result, null, type);
assert.equal(cancelled, true, type);
}
});
test('CCTV upstream frame fetch rejects truncated or oversized MJPEG parts', async () => {
const boundary = 'DIGIEVER';
const full = Buffer.from([0xff, 0xd8, 0x01, 0x02, 0xff, 0xd9]);
const truncated = Buffer.from([0xff, 0xd8, 0x01, 0x02]);
for (const [chunks, maxBytes] of [
[jpegPart(boundary, truncated, 'image/jpeg', full.length), 1024],
[jpegPart(boundary, full), 5],
]) {
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
maxBytes,
fetchImpl: async () =>
multipartResponse(
'multipart/x-mixed-replace; boundary=' + boundary,
[chunks],
{ onCancel: () => { cancelled = true; } },
),
},
);
assert.equal(result, null);
assert.equal(cancelled, true);
}
});
test('CCTV upstream frame fetch does not cross into a later part when the first JPEG is truncated', async () => {
const boundary = 'DIGIEVER';
const truncated = Buffer.from([0xff, 0xd8, 0x01, 0x02]);
const valid = Buffer.from([0xff, 0xd8, 0x03, 0x04, 0xff, 0xd9]);
const wire = Buffer.concat([
Buffer.from(
'--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n',
),
truncated,
Buffer.from(
'\r\n--' +
boundary +
'\r\nContent-Type: image/jpeg\r\nContent-Length: ' +
valid.length +
'\r\n\r\n',
),
valid,
Buffer.from('\r\n--' + boundary + '--\r\n'),
]);
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
maxBytes: 1024,
fetchImpl: async () =>
multipartResponse(
'multipart/x-mixed-replace; boundary=' + boundary,
[wire],
{ onCancel: () => { cancelled = true; } },
),
},
);
assert.equal(result, null);
assert.equal(cancelled, true);
});
test('CCTV upstream frame fetch ignores MJPEG unless the source is registered as MJPEG', async () => {
const boundary = 'DIGIEVER';
const jpeg = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
fetchImpl: async () =>
multipartResponse(
'multipart/x-mixed-replace; boundary=' + boundary,
[jpegPart(boundary, jpeg)],
{ onCancel: () => { cancelled = true; } },
),
},
);
assert.equal(result, null);
assert.equal(cancelled, true);
});
test('CCTV upstream frame fetch accepts a CRLF preamble and a boundary that already starts with --', async () => {
const jpeg = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
for (const [type, wire] of [
[
'multipart/x-mixed-replace; boundary=DIGIEVER',
Buffer.concat([Buffer.from('\r\n'), jpegPart('DIGIEVER', jpeg)]),
],
[
'multipart/x-mixed-replace; boundary=--myboundary',
Buffer.concat([
Buffer.from(
'--myboundary\r\nContent-Type: image/jpeg\r\nContent-Length: ' +
jpeg.length +
'\r\n\r\n',
),
jpeg,
Buffer.from('\r\n--myboundary\r\n'),
]),
],
]) {
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
fetchImpl: async () => multipartResponse(type, [wire]),
},
);
assert.deepEqual(result?.body, jpeg, type);
}
});
test('CCTV MJPEG part without Content-Length ends at the next delimiter, not at an inner JPEG end marker', async () => {
const boundary = 'DIGIEVER';
// An EXIF thumbnail's own FF D9 sits inside the image.
const jpeg = Buffer.from([0xff, 0xd8, 0x01, 0xff, 0xd9, 0x02, 0x03, 0xff, 0xd9]);
const wire = Buffer.concat([
Buffer.from('--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n'),
jpeg,
Buffer.from('\r\n--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n'),
]);
// Delivered one byte at a time, as a trickling upstream would.
const chunks = [...wire].map((byte) => Buffer.from([byte]));
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
fetchImpl: async () =>
multipartResponse('multipart/x-mixed-replace; boundary=' + boundary, chunks),
},
);
assert.deepEqual(result?.body, jpeg);
});
+3 -87
View File
@@ -182,86 +182,6 @@ test('loader deduplicates cameras that share a camera number', async (t) => {
]); ]);
}); });
test('falls back to Highway Bureau MJPEG cameras when the Freeway catalog fails', async (t) => {
const highwayUrl = 'https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml';
const requests = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url, options) => {
const href = String(url);
requests.push(href);
assert.equal(options.redirect, 'manual');
if (href === TAIWAN_FREEWAY_CCTV_URL)
return new Response('', { status: 503 });
if (href === highwayUrl) {
return new Response(
cctvXml(
block({
id: 'CCTV-14-0620-009-002',
stream: 'https://cctv-ss02.thb.gov.tw:443/T62-9K+020',
lat: '24.9967',
lon: '121.4521',
}),
),
{ headers: { 'content-type': 'application/xml' } },
);
}
throw new Error('Unexpected Taiwan feed URL: ' + href);
});
const sources = await loadTaiwanSources();
assert.deepEqual(requests, [TAIWAN_FREEWAY_CCTV_URL, highwayUrl]);
assert.equal(sources.length, 1);
assert.equal(sources[0].id, 'tw-thb-cctv-14-0620-009-002');
assert.equal(sources[0].cityId, 'taiwan');
assert.equal(sources[0].provider, 'Highway Bureau, MOTC');
assert.equal(sources[0].feedType, 'mjpeg');
assert.equal(sources[0].sourceKind, 'taiwan-highway-motc');
assert.equal(new URL(sources[0].url).hostname, 'cctv-ss02.thb.gov.tw');
assert.equal(new URL(sources[0].url).pathname, '/T62-9K+020');
});
test('the Highway Bureau fallback registers only approved HTTPS camera servers', async (t) => {
const highwayUrl = 'https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml';
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
if (String(url) === TAIWAN_FREEWAY_CCTV_URL)
return new Response('', { status: 503 });
if (String(url) === highwayUrl) {
return new Response(
cctvXml(
block({
id: 'CCTV-14-0620-009-001',
stream: 'https://cctv-ss09.thb.gov.tw/T62-9K+019',
}),
block({
id: 'CCTV-14-0620-009-003',
stream: 'http://cctv-ss02.thb.gov.tw/T62-9K+021',
}),
block({
id: 'CCTV-14-0620-009-004',
stream: 'https://cctv-ss02.thb.gov.tw:8443/T62-9K+022',
}),
block({
id: 'CCTV-14-0620-009-002',
stream: 'https://cctv-ss02.thb.gov.tw:443/T62-9K+020',
}),
),
{ headers: { 'content-type': 'application/xml' } },
);
}
throw new Error('Unexpected Taiwan feed URL: ' + String(url));
});
const sources = await loadTaiwanSources();
assert.deepEqual(
sources.map((source) => source.id),
['tw-thb-cctv-14-0620-009-002'],
);
});
test('a redirected or failed catalog contributes an empty pack', async (t) => { test('a redirected or failed catalog contributes an empty pack', async (t) => {
t.mock.method(console, 'warn', () => {}); t.mock.method(console, 'warn', () => {});
for (const status of [302, 503]) { for (const status of [302, 503]) {
@@ -294,17 +214,13 @@ test('the Taiwan pack is in the catalog by default', async (t) => {
); );
}); });
test('CCTV_TAIWAN_ENABLED=0 makes no Taiwan catalog request', async (t) => { test('CCTV_TAIWAN_ENABLED=0 makes no Freeway Bureau request', async (t) => {
const { requested, sources } = await runCatalogWithTaiwanFlag(t, '0'); const { requested, sources } = await runCatalogWithTaiwanFlag(t, '0');
assert.ok(requested.length > 0, 'the other packs still refreshed'); assert.ok(requested.length > 0, 'the other packs still refreshed');
assert.equal( assert.equal(
requested.some((href) => href.includes('freeway.gov.tw')), requested.some((href) => href.includes('freeway.gov.tw')),
false, false,
); );
assert.equal(
requested.some((href) => href.includes('cctv-maintain.thb.gov.tw')),
false,
);
assert.ok(!sources.some((c) => c.cityId === 'taiwan')); assert.ok(!sources.some((c) => c.cityId === 'taiwan'));
}); });
@@ -334,12 +250,12 @@ test('a failed download is not retried until the backoff ends', async (t) => {
t.mock.method(Date, 'now', () => 1_000_000); t.mock.method(Date, 'now', () => 1_000_000);
assert.deepEqual(await loadTaiwanSources(), []); assert.deepEqual(await loadTaiwanSources(), []);
assert.deepEqual(await loadTaiwanSources(), []); assert.deepEqual(await loadTaiwanSources(), []);
assert.equal(calls, 2, 'the second refresh made no request'); assert.equal(calls, 1, 'the second refresh made no request');
Date.now.mock.mockImplementation( Date.now.mock.mockImplementation(
() => 1_000_000 + TAIWAN_RETRY_AFTER_FAILURE_MS + 1, () => 1_000_000 + TAIWAN_RETRY_AFTER_FAILURE_MS + 1,
); );
await loadTaiwanSources(); await loadTaiwanSources();
assert.equal(calls, 4, 'the backoff ends after an hour'); assert.equal(calls, 2, 'the backoff ends after an hour');
}); });
test('a failed download serves the last good table', async (t) => { test('a failed download serves the last good table', async (t) => {
+3 -3
View File
@@ -10,7 +10,7 @@ import {
TORONTO_RESCU_LIST_URL, TORONTO_RESCU_LIST_URL,
TORONTO_RESCU_IMAGE_ORIGIN, TORONTO_RESCU_IMAGE_ORIGIN,
TORONTO_MAX_CATALOG_BYTES, TORONTO_MAX_CATALOG_BYTES,
CCTV_SHARED_SOURCE_SLOTS, DEFAULT_CCTV_MAX_SOURCES,
} from '../../server/providers/cctv/constants.js'; } from '../../server/providers/cctv/constants.js';
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js'; import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
import { allocateSourceCap } from '../../server/providers/cctv/cap.js'; import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
@@ -418,6 +418,6 @@ test('CCTV_TORONTO_ENABLED=0 keeps the lane from being loaded at all', async (t)
} }
}); });
test('the shared catalog slots are not raised to make room for this pack', () => { test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000); assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
}); });
-316
View File
@@ -1,316 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
DEFAULT_WORLD_CCTV_SOURCE_FILE,
WORLD_CCTV_SOURCE_COUNT,
} from '../../server/providers/cctv/constants.js';
import {
isMlitRiverAdjustmentImage,
loadWorldCameraSources,
} from '../../server/providers/cctv/worldCameras.js';
import {
loadQuebecSources,
QUEBEC_CAMERA_SOURCE_COUNT,
quebec511VideoUrl,
quebecBrowserVideoEnabled,
} from '../../server/providers/cctv/quebec.js';
import { cctvProxy } from '../../server/providers/cctv.js';
const REPO_ROOT = path.resolve(import.meta.dirname, '../..');
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0xff, 0xd9]);
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** A temporary source root holding the given World and Québec catalogs. */
function sourceRoot(t, { world = [], quebec = null } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-world-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const worldFile = path.join(root, DEFAULT_WORLD_CCTV_SOURCE_FILE);
fs.mkdirSync(path.dirname(worldFile), { recursive: true });
fs.writeFileSync(worldFile, JSON.stringify(world));
if (quebec) {
fs.mkdirSync(path.join(root, 'public'), { recursive: true });
fs.writeFileSync(
path.join(root, 'public/quebec-cameras.json'),
JSON.stringify({ cameras: quebec }),
);
}
return root;
}
const oregonRow = (overrides = {}) => ({
id: 'world-or-764',
name: '3rd St at Harwood MP18.35',
provider: 'TripCheck (ODOT)',
lat: 44.3029,
lon: -120.8549,
sourceKind: 'tripcheck-odot',
url: 'https://www.tripcheck.com/roadcams/cams/Harwood_pid3527.jpg',
snapshotUrl: 'https://www.tripcheck.com/roadcams/cams/Harwood_pid3527.jpg',
...overrides,
});
test('the shipped World catalog loads every curated row', (t) => {
quiet(t);
const sources = loadWorldCameraSources({ sourceRoot: REPO_ROOT });
assert.equal(sources.length, WORLD_CCTV_SOURCE_COUNT);
const byKind = Object.groupBy(sources, (source) => source.sourceKind);
// Iceland comes from the live Iceland pack, never a second World copy.
assert.equal(byKind['vegagerdin-road'], undefined);
assert.equal(byKind['tripcheck-odot'].length, 1176);
assert.equal(byKind['mlit-river'].length, 1054);
for (const source of byKind['mlit-river'])
assert.equal(source.frameRefreshMs, 10 * 60 * 1000);
});
test('the World loader accepts a pinned row and rejects anything outside its policy', (t) => {
quiet(t);
const rejected = [
['unknown kind', { sourceKind: 'nzta-journeys' }],
[
'Iceland duplicates the Iceland pack',
{
sourceKind: 'vegagerdin-road',
lat: 64.291877,
lon: -15.028035,
url: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg',
snapshotUrl: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg',
},
],
['wrong id prefix', { id: 'world-jp-river-7075' }],
['outside bounds', { lat: 40, lon: -15 }],
['other host', { url: 'https://evil.test/roadcams/cams/a.jpg' }],
['other directory', { url: 'https://www.tripcheck.com/other/a.jpg' }],
['query string', { url: oregonRow().url + '?x=1' }],
['not a jpg', { url: 'https://www.tripcheck.com/roadcams/cams/a.png' }],
['plain http', { url: oregonRow().url.replace('https:', 'http:') }],
[
'url and snapshot differ',
{ snapshotUrl: 'https://www.tripcheck.com/roadcams/cams/b.jpg' },
],
];
const root = sourceRoot(t, {
world: [
oregonRow(),
oregonRow(), // duplicate id
...rejected.map(([, overrides], i) =>
oregonRow({ id: `world-or-${9000 + i}`, ...overrides }),
),
],
});
const sources = loadWorldCameraSources({ sourceRoot: root });
assert.deepEqual(
sources.map((source) => source.id),
['world-or-764'],
);
assert.equal(sources[0].frameRefreshMs, 5 * 60 * 1000);
assert.equal(sources[0].feedType, 'image');
});
test('the World loader fails soft on a missing or malformed catalog', (t) => {
quiet(t);
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-world-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
assert.deepEqual(loadWorldCameraSources({ sourceRoot: root }), []);
const file = path.join(root, DEFAULT_WORLD_CCTV_SOURCE_FILE);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, '{not json');
assert.deepEqual(loadWorldCameraSources({ sourceRoot: root }), []);
});
test('only the exact MLIT adjustment image is recognised', () => {
assert.equal(isMlitRiverAdjustmentImage({ ok: true, body: JPEG }), false);
assert.equal(isMlitRiverAdjustmentImage(null), false);
assert.equal(isMlitRiverAdjustmentImage({ ok: false, body: JPEG }), false);
});
test('the shipped Québec catalog loads every location', (t) => {
quiet(t);
const sources = loadQuebecSources({ sourceRoot: REPO_ROOT });
assert.equal(sources.length, QUEBEC_CAMERA_SOURCE_COUNT);
for (const source of sources) {
assert.equal(source.url, '', 'the server never fetches Québec media');
assert.equal(source.snapshotUrl, '');
}
});
test('the Québec loader maps ids by kind, keeps https viewer links, and splits the MTQ credit', (t) => {
quiet(t);
const at = { lat: 45.51651, lon: -73.5502 };
const root = sourceRoot(t, {
quebec: [
{ id: '3735', kind: 'traffic', name: 'Route 136', viewerUrl: 'https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735', ...at },
{ id: 'mtl-465', kind: 'montreal', name: 'Montréal 465', viewerUrl: 'http://ville.montreal.qc.ca/x', ...at },
{ id: 'web-1', kind: 'webcam', name: 'Tremblant', operator: 'Station Mont-Tremblant', viewerUrl: 'https://user:[email protected]/', ...at },
{ id: 'abc', kind: 'traffic', ...at }, // non-numeric traffic id
{ id: '3735', kind: 'traffic', ...at }, // duplicate
{ id: '9', kind: 'traffic', lat: 40, lon: -73.5 }, // outside Québec
],
});
const sources = loadQuebecSources({ sourceRoot: root });
const byId = new Map(sources.map((source) => [source.id, source]));
assert.deepEqual([...byId.keys()], ['qc-511-3735', 'qc-mtl-465', 'qc-web-1']);
const mtq = byId.get('qc-511-3735');
assert.equal(mtq.sourceKind, 'quebec-511-browser');
assert.match(mtq.credit, /Locations: MTQ · CC BY 4\.0/);
assert.match(mtq.credit, /Video: Québec 511/);
assert.doesNotMatch(mtq.license, /^Données Québec \/ MTQ, CC BY 4\.0$/);
assert.equal(byId.get('qc-mtl-465').sourceUrl, '', 'http viewer links are dropped');
assert.equal(byId.get('qc-web-1').sourceUrl, '', 'credentialed links are dropped');
assert.equal(
quebec511VideoUrl('qc-511-3735'),
'https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4',
);
assert.equal(quebec511VideoUrl('qc-mtl-465'), '');
});
test('Québec 511 browser video is opt-in', () => {
assert.equal(quebecBrowserVideoEnabled({}), false);
assert.equal(quebecBrowserVideoEnabled({ CCTV_QUEBEC_VIDEO_ENABLED: '0' }), false);
assert.equal(quebecBrowserVideoEnabled({ CCTV_QUEBEC_VIDEO_ENABLED: ' 1 ' }), true);
});
/** Mount the real proxy with a fixed fetch, and drive requests through it. */
function mount(t, root, respond) {
withEnv(t, {
CCTV_SOURCES_FILE: 'absent-source-file.json',
CCTV_SOURCES_JSON: undefined,
CCTV_FORCE_AUSTIN: '0',
CCTV_MAX_SOURCES: undefined,
// A configured key makes any Street View fallback observable.
GOOGLE_MAPS_SERVER_API_KEY: 'test-google-key',
});
const requests = [];
t.mock.method(globalThis, 'fetch', async (url, init = {}) => {
requests.push(String(url));
return respond(String(url), init) || new Response('down', { status: 503 });
});
let handler;
cctvProxy({ sourceRoot: root }).configureServer({
middlewares: {
use(_route, fn) {
handler = fn;
},
},
});
const call = async (url) => {
const res = {
writeHead(status, headers) {
Object.assign(this, { status, headers });
},
end(body) {
this.body = body;
},
on() {},
once() {},
};
await handler({ url, method: 'GET', headers: {}, on() {} }, res);
return res;
};
return { call, requests };
}
test('/sources lists Québec browser media only when the operator opts in', async (t) => {
quiet(t);
const root = sourceRoot(t, {
quebec: [{ id: '3735', kind: 'traffic', name: 'Route 136', lat: 45.5, lon: -73.5 }],
});
withEnv(t, { CCTV_QUEBEC_VIDEO_ENABLED: undefined });
const app = mount(t, root, () => null);
const off = JSON.parse((await app.call('/sources')).body).sources;
assert.equal(off.find((s) => s.id === 'qc-511-3735').browserMediaUrl, '');
process.env.CCTV_QUEBEC_VIDEO_ENABLED = '1';
const on = JSON.parse((await app.call('/sources')).body).sources;
assert.match(on.find((s) => s.id === 'qc-511-3735').browserMediaUrl, /camera\.ashx\?id=3735&format=mp4$/);
});
test('a World frame is fetched once per refresh floor and the cooldown never spends Street View', async (t) => {
quiet(t);
const root = sourceRoot(t, { world: [oregonRow()] });
let answer = () => new Response(JPEG, { headers: { 'content-type': 'image/jpeg' } });
const app = mount(t, root, (url) =>
url === oregonRow().url ? answer() : null,
);
const frame = '/frame/world-or-764';
const upstreamHits = () =>
app.requests.filter((url) => url === oregonRow().url).length;
const first = await app.call(frame);
assert.equal(first.headers['X-CCTV-Source'], 'upstream-image');
const second = await app.call(frame);
assert.equal(second.headers['X-CCTV-Source'], 'upstream-image');
assert.equal(upstreamHits(), 1, 'the second view reuses the cached frame');
// A camera whose first fetch fails is inside its failure backoff: it gets
// the placeholder, not Google Street View, and no second upstream request.
const failRoot = sourceRoot(t, { world: [oregonRow()] });
answer = () => new Response('down', { status: 503 });
const failing = mount(t, failRoot, (url) =>
url === oregonRow().url ? answer() : null,
);
const failed = await failing.call(frame);
assert.notEqual(failed.headers['X-CCTV-Source'], 'upstream-image');
const retry = await failing.call(frame);
assert.equal(retry.headers['X-CCTV-Source'], 'synthetic');
assert.equal(
failing.requests.filter((url) => url === oregonRow().url).length,
1,
'the backoff holds the provider cadence',
);
assert.equal(
failing.requests.filter((url) => url.includes('maps.googleapis.com/maps/api/streetview')).length,
1,
'only the first miss, outside any cooldown, may try Street View',
);
});
test('Ottawa frames are signed with the certificate at fetch time only', async (t) => {
quiet(t);
const root = sourceRoot(t);
withEnv(t, { OTTAWA_CAMERA_CERTIFICATE: 'CERT123' });
const app = mount(t, root, (url) => {
if (url === 'https://traffic.ottawa.ca/map/service/camera')
return Response.json({
cameras: [
{ camera_number: 16, name: 'Bank & Hunt Club', latitude: 45.35, longitude: -75.65, cameraOwner: 'CITY' },
],
});
if (url.startsWith('https://traffic.ottawa.ca/opendata/camera'))
return new Response(JPEG, { headers: { 'content-type': 'image/jpeg' } });
return null;
});
const sources = JSON.parse((await app.call('/sources')).body).sources;
const ottawa = sources.find((source) => source.id === 'ottawa-16');
assert.ok(ottawa);
assert.doesNotMatch(JSON.stringify(sources), /CERT123/, 'no catalog field carries the certificate');
const frame = await app.call('/frame/ottawa-16');
assert.equal(frame.headers['X-CCTV-Source'], 'upstream-image');
await app.call('/frame/ottawa-16');
const hits = app.requests.filter((url) => url.includes('/opendata/camera'));
assert.deepEqual(hits, [
'https://traffic.ottawa.ca/opendata/camera?c=16&certificate=CERT123&id=gev',
], 'one signed request per camera per minute');
});
-49
View File
@@ -299,38 +299,6 @@ export const DATA_CREDITS = [
'CCTV static data (MOTC 2.0), released under the ' + 'CCTV static data (MOTC 2.0), released under the ' +
'<a href="https://data.gov.tw/license" target="_blank" rel="noopener">Open Government Data License, Taiwan (Version 1.0)</a>', '<a href="https://data.gov.tw/license" target="_blank" rel="noopener">Open Government Data License, Taiwan (Version 1.0)</a>',
}, },
{
key: 'ottawa-cctv',
html:
'Traffic cameras (Ottawa, with an access certificate): ' +
'<a href="https://traffic.ottawa.ca/en/traffic-map-data-lists-and-resources/about-the-data" target="_blank" rel="noopener">City of Ottawa Traffic Camera Images API</a>',
},
{
key: 'quebec-cctv',
html:
'Camera locations (Québec): Ministère des Transports et de la Mobilité durable via ' +
'<a href="https://www.donneesquebec.ca/" target="_blank" rel="noopener">Données Québec</a> ' +
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>); ' +
'Montréal camera locations © OpenStreetMap contributors (ODbL). ' +
'Video (when enabled) and linked webcam pages stay with ' +
'<a href="https://www.quebec511.info/" target="_blank" rel="noopener">Québec 511</a> and each operator, under their own terms',
},
{
key: 'tripcheck-cctv',
html:
'Road camera stills (Oregon): camera courtesy of ODOT, via ' +
'<a href="https://www.tripcheck.com/" target="_blank" rel="noopener">TripCheck</a>; ' +
'partner cameras (counties, cities, Caltrans, WSDOT) are named in the camera label',
},
{
key: 'mlit-river-cctv',
html:
'River camera stills (Japan): 出典:国土交通省 ' +
'<a href="https://www.river.go.jp/" target="_blank" rel="noopener">川の防災情報</a> ' +
'(Ministry of Land, Infrastructure, Transport and Tourism; ' +
'<a href="https://www.river.go.jp/kawabou/kwb_apend/html/caution.html" target="_blank" rel="noopener">terms</a>), ' +
'fetched on view, at most every ten minutes per camera',
},
{ {
key: 'calgary-cctv', key: 'calgary-cctv',
html: html:
@@ -353,15 +321,6 @@ export const DATA_CREDITS = [
'(Llicència oberta d’ús d’informació - Catalunya); some cameras are supplied by partners ' + '(Llicència oberta d’ús d’informació - Catalunya); some cameras are supplied by partners ' +
'(Ajuntament de Barcelona, Ajuntament de Terrassa), each named in the CCTV panel', '(Ajuntament de Barcelona, Ajuntament de Terrassa), each named in the CCTV panel',
}, },
{
key: 'iceland-cctv',
html:
'CCTV cameras &amp; frames (Iceland): road camera images, fetched live, from the ' +
'<a href="https://www.vegagerdin.is/" target="_blank" rel="noopener">Icelandic Road and Coastal Administration (Vegagerðin)</a> — ' +
'<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a> ' +
'(<a href="https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions" target="_blank" rel="noopener">terms</a>); ' +
'no endorsement by IRCA is implied',
},
{ {
key: 'kingcounty-cctv', key: 'kingcounty-cctv',
html: html:
@@ -386,14 +345,6 @@ export const DATA_CREDITS = [
'<a href="https://data.gov.hk/en-data/dataset/hk-td-tis_2-traffic-snapshot-images" target="_blank" rel="noopener">Transport Department / DATA.GOV.HK</a> ' + '<a href="https://data.gov.hk/en-data/dataset/hk-td-tis_2-traffic-snapshot-images" target="_blank" rel="noopener">Transport Department / DATA.GOV.HK</a> ' +
'(<a href="https://data.gov.hk/en/terms-and-conditions" target="_blank" rel="noopener">Terms of Use</a> — attribution required)', '(<a href="https://data.gov.hk/en/terms-and-conditions" target="_blank" rel="noopener">Terms of Use</a> — attribution required)',
}, },
{
key: 'webcamromania-cctv',
html:
'Giroc camera (when configured): ' +
'<a href="https://webcamromania.ro/webcam-orase/webcam-giroc/" target="_blank" rel="noopener">Webcam Romania</a>. ' +
'Video rights remain with the camera operator; ' +
'<a href="https://webcamromania.ro/termeni-si-conditii/" target="_blank" rel="noopener">provider terms</a> apply.',
},
{ {
key: 'vegvesen-cctv', key: 'vegvesen-cctv',
html: html:
-14
View File
@@ -153,17 +153,3 @@ test('every Street Level imagery source is in the attribution lightbox', () => {
`${key} has no credit`, `${key} has no credit`,
); );
}); });
test('the PR #1506 camera packs are credited with their sources and terms', () => {
const html = (key) => DATA_CREDITS.find((entry) => entry.key === key)?.html;
assert.match(html('ottawa-cctv') || '', /traffic\.ottawa\.ca/);
assert.match(html('quebec-cctv') || '', /CC BY 4\.0/);
assert.match(html('quebec-cctv') || '', /OpenStreetMap contributors/);
assert.match(html('tripcheck-cctv') || '', /courtesy of ODOT/);
// MLIT's terms ask for the source in this form, with the page link.
assert.match(html('mlit-river-cctv') || '', /出典:国土交通省/);
assert.match(
html('mlit-river-cctv') || '',
/href="https:\/\/www\.river\.go\.jp\//,
);
});
-3
View File
@@ -335,9 +335,6 @@ const OPTION_GROUPS = Object.freeze({
osm: 'o', osm: 'o',
hybrid: 'h', hybrid: 'h',
}), }),
// Street Traffic row toggles: flow-colored road lines, moving dots.
booleanOption('lines', 'l', false),
booleanOption('dots', 'd', true),
]), ]),
'weather-lightning': Object.freeze([ 'weather-lightning': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], { enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { LayerLifecycle } from './lifecycle.js';
const layer = (id) => ({
id,
name: id,
icon: '',
source: 'test',
async init() {},
async enable() {},
async update() {},
async disable() {},
async destroy() {},
getStats: () => ({ count: 0, lastUpdate: null }),
});
test('library layers register only after sealing and only in the lib- namespace', async () => {
const manager = new LayerLifecycle({});
const activity = [];
manager.subscribeActivity((change) => activity.push(change.type));
manager.register(layer('earthquakes'));
assert.throws(
() => manager.registerLibraryLayer(layer('lib-night-lights')),
/after the catalog is sealed/,
);
manager.finalizeRegistrations([
{ id: 'earthquakes', disposition: 'enabled-only' },
]);
assert.throws(
() => manager.registerLibraryLayer(layer('radio')),
/lib- namespace/,
);
assert.throws(() => manager.register(layer('lib-x-y-z')), /finalized/);
manager.registerLibraryLayer(layer('lib-night-lights'));
assert.equal(manager.isLibraryLayer('lib-night-lights'), true);
assert.equal(manager.isLibraryLayer('earthquakes'), false);
const rows = manager.getAll();
assert.equal(
rows.find(({ id }) => id === 'earthquakes').libraryEntryId,
null,
);
assert.equal(
rows.find(({ id }) => id === 'lib-night-lights').libraryEntryId,
'night-lights',
);
assert.equal(await manager.unregisterLibraryLayer('earthquakes'), false);
assert.equal(manager.layers.has('earthquakes'), true);
await manager.setEnabled('lib-night-lights', true, { origin: 'user' });
assert.equal(await manager.unregisterLibraryLayer('lib-night-lights'), true);
assert.equal(manager.layers.has('lib-night-lights'), false);
assert.equal(manager.isLibraryLayer('lib-night-lights'), false);
assert.equal(activity.filter((type) => type === 'registrations').length, 2);
});
+42
View File
@@ -1,4 +1,5 @@
import { cancelCameraArrival } from './cameraArrival.js'; import { cancelCameraArrival } from './cameraArrival.js';
import { libraryEntryIdOf } from '../library/manifest.js';
function cloneLayerParams(value) { function cloneLayerParams(value) {
if (Array.isArray(value)) return value.map(cloneLayerParams); if (Array.isArray(value)) return value.map(cloneLayerParams);
@@ -97,6 +98,7 @@ export class LayerLifecycle {
this._registrationDispositions = null; this._registrationDispositions = null;
this._allowQaRegistration = allowQaRegistration === true; this._allowQaRegistration = allowQaRegistration === true;
this._qaLayerIds = new Set(); this._qaLayerIds = new Set();
this._libraryLayerIds = new Set();
} }
register(layerModule) { register(layerModule) {
@@ -125,6 +127,41 @@ export class LayerLifecycle {
return destroyed; return destroyed;
} }
/**
* Register a Data Library layer after the catalog is sealed. Only ids in
* the library namespace (`lib-<entry id>`) are accepted, so this seam can
* never stand in for a core layer, and library layers never join the core
* share-token registry: their selection travels in `lib=` instead.
* @param {object} layerModule
* @returns {string} The registered id.
*/
registerLibraryLayer(layerModule) {
if (!this._registrationsFinalized)
throw new Error('Library layers register after the catalog is sealed');
if (!libraryEntryIdOf(layerModule?.id))
throw new Error('Library layer ids must use the lib- namespace');
this._registerLayer(layerModule);
this._libraryLayerIds.add(layerModule.id);
this._publishActivity({ type: 'registrations', layerId: layerModule.id });
return layerModule.id;
}
/** Destroy a layer registered through `registerLibraryLayer`. */
async unregisterLibraryLayer(layerId) {
if (!this._libraryLayerIds.has(layerId)) return false;
const destroyed = await this.destroyLayer(layerId);
if (destroyed) {
this._libraryLayerIds.delete(layerId);
this._publishActivity({ type: 'registrations', layerId });
}
return destroyed;
}
/** Whether a registered layer came from the Data Library. */
isLibraryLayer(layerId) {
return this._libraryLayerIds.has(layerId);
}
_registerLayer(layerModule) { _registerLayer(layerModule) {
if (!layerModule || typeof layerModule.id !== 'string' || !layerModule.id) { if (!layerModule || typeof layerModule.id !== 'string' || !layerModule.id) {
throw new Error('Data layer must provide a stable id'); throw new Error('Data layer must provide a stable id');
@@ -2231,6 +2268,7 @@ export class LayerLifecycle {
} }
this.layers.delete(layerId); this.layers.delete(layerId);
this._qaLayerIds.delete(layerId); this._qaLayerIds.delete(layerId);
this._libraryLayerIds.delete(layerId);
return true; return true;
} }
@@ -2274,6 +2312,10 @@ export class LayerLifecycle {
// showInTogglePanel). The layer reports stats.keyRequired while that // showInTogglePanel). The layer reports stats.keyRequired while that
// key is absent; the panel builds the guidance text from the pair. // key is absent; the panel builds the guidance text from the pair.
requiresKeyId: entry.module.requiresKeyId || null, requiresKeyId: entry.module.requiresKeyId || null,
// Data Library entry id; the panel groups these under Library.
libraryEntryId: this._libraryLayerIds.has(id)
? libraryEntryIdOf(id)
: null,
enabled: entry.enabled, enabled: entry.enabled,
lifecycleState: entry.lifecycleState, lifecycleState: entry.lifecycleState,
lifecycleUncertain: entry.lifecycleUncertain, lifecycleUncertain: entry.lifecycleUncertain,
+47 -6
View File
@@ -86,6 +86,13 @@ export function localInfrastructureOverlayCopy(properties, layerId) {
if (river && river.toLocaleLowerCase() !== title.toLocaleLowerCase()) { if (river && river.toLocaleLowerCase() !== title.toLocaleLowerCase()) {
details.push(clampCardLine(river)); details.push(clampCardLine(river));
} }
} else if (Array.isArray(props.__gevDetails)) {
// Data Library features carry their card lines from the manifest
// (src/library/formats.js presentLibraryProperties).
for (const line of props.__gevDetails.slice(0, 2)) {
const clean = cleanLabel(line);
if (clean) details.push(clampCardLine(clean));
}
} }
return { title, details }; return { title, details };
@@ -292,7 +299,10 @@ export function localDatasetError(error) {
* A minimal, rock-solid native implementation for loading local GeoJSON Data. * A minimal, rock-solid native implementation for loading local GeoJSON Data.
* Draws 3D stems (polylines) attached to Point entities and ensures * Draws 3D stems (polylines) attached to Point entities and ensures
* standard scene.pick natively clicks them. * standard scene.pick natively clicks them.
* @param {object} options Dataset URL, identity, appearance and optional Cesium adapters. * @param {object} options Dataset URL (or a `loadFeatures({signal})` loader
* resolving to GeoJSON features), identity, appearance (an optional
* `featureColor(properties)` returns a CSS colour per feature) and optional
* Cesium adapters.
* @param {object} services Caller-owned operations; see docs/INFRASTRUCTURE-LAYERS.md. * @param {object} services Caller-owned operations; see docs/INFRASTRUCTURE-LAYERS.md.
* @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats/getAnalystRecords. * @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats/getAnalystRecords.
* One live instance per layer id is allowed in a given viewer/context/overlay host. * One live instance per layer id is allowed in a given viewer/context/overlay host.
@@ -310,6 +320,8 @@ export function createLocalGeoJsonLayer(
labels = true, labels = true,
labelMax = DEFAULT_LABEL_MAX, labelMax = DEFAULT_LABEL_MAX,
labelGridPx = DEFAULT_LABEL_GRID_PX, labelGridPx = DEFAULT_LABEL_GRID_PX,
loadFeatures = null,
featureColor = null,
screenSpaceEventHandlerFactory = (canvas) => screenSpaceEventHandlerFactory = (canvas) =>
new Cesium.ScreenSpaceEventHandler(canvas), new Cesium.ScreenSpaceEventHandler(canvas),
projectToWindow = (scene, position) => projectToWindow = (scene, position) =>
@@ -585,7 +597,17 @@ export function createLocalGeoJsonLayer(
let addedToScene = false; let addedToScene = false;
try { try {
let features = _cachedFeatures; let features = _cachedFeatures;
if (!features) { if (!features && loadFeatures) {
// A caller-owned loader (the Data Library) fetches and bounds
// its own live dataset; this layer only renders it.
features = await loadFeatures({
signal: _loadController.signal,
});
if (_destroyed) return;
if (!Array.isArray(features))
throw new Error('dataset unavailable');
_cachedFeatures = features;
} else if (!features) {
const response = await fetch(url, { const response = await fetch(url, {
signal: _loadController.signal, signal: _loadController.signal,
}); });
@@ -639,9 +661,21 @@ export function createLocalGeoJsonLayer(
_stemRecords = []; _stemRecords = [];
_stemGeometryDirty = true; _stemGeometryDirty = true;
// An optional per-feature colour (Data Library colorBy); one
// Cesium colour per distinct CSS value.
const colorCache = new Map([[color, baseColor]]);
const colorFor = (entity) => {
if (typeof featureColor !== 'function') return baseColor;
const css = featureColor(propertyObject(entity)) || color;
if (!colorCache.has(css))
colorCache.set(css, Cesium.Color.fromCssColorString(css));
return colorCache.get(css);
};
for (let i = 0; i < entities.length; i++) { for (let i = 0; i < entities.length; i++) {
const feature = entities[i]; const feature = entities[i];
feature.__localLayerId = id; // Tag it so our click handler knows it belongs to this layer feature.__localLayerId = id; // Tag it so our click handler knows it belongs to this layer
const featureBaseColor = colorFor(feature);
let pos = feature.position?.getValue(Cesium.JulianDate.now()); let pos = feature.position?.getValue(Cesium.JulianDate.now());
@@ -649,7 +683,10 @@ export function createLocalGeoJsonLayer(
// It's a polygon or line // It's a polygon or line
if (feature.polygon) { if (feature.polygon) {
feature.polygon.outline = true; feature.polygon.outline = true;
feature.polygon.outlineColor = baseColor; feature.polygon.outlineColor = featureBaseColor;
if (featureBaseColor !== baseColor)
feature.polygon.material =
featureBaseColor.withAlpha(0.3);
// Calculate center point for the stem // Calculate center point for the stem
const hierarchy = feature.polygon.hierarchy?.getValue( const hierarchy = feature.polygon.hierarchy?.getValue(
@@ -716,11 +753,11 @@ export function createLocalGeoJsonLayer(
feature.polyline = new Cesium.PolylineGraphics({ feature.polyline = new Cesium.PolylineGraphics({
positions: stemPositionBuffers[0], positions: stemPositionBuffers[0],
width: 3.5, width: 3.5,
material: new Cesium.ColorMaterialProperty(baseColor), material: new Cesium.ColorMaterialProperty(featureBaseColor),
}); });
feature.point = new Cesium.PointGraphics({ feature.point = new Cesium.PointGraphics({
pixelSize: 10, pixelSize: 10,
color: baseColor, color: featureBaseColor,
outlineColor: Cesium.Color.BLACK, outlineColor: Cesium.Color.BLACK,
outlineWidth: 2, outlineWidth: 2,
// Never depth-cull the anchor against the photoreal mesh — // Never depth-cull the anchor against the photoreal mesh —
@@ -749,7 +786,7 @@ export function createLocalGeoJsonLayer(
position: tip, position: tip,
properties, properties,
priority, priority,
accent: color, accent: featureBaseColor.toCssHexString(),
}) })
: null, : null,
}); });
@@ -1323,6 +1360,10 @@ function labelPriorityFromProperties(props, layerId) {
if (props.output || tags['plant:output:electricity']) score += 120; if (props.output || tags['plant:output:electricity']) score += 120;
if (layerId === 'local-dams') score += 80; if (layerId === 'local-dams') score += 80;
if (layerId === 'local-datacenters') score += 60; if (layerId === 'local-datacenters') score += 60;
// Data Library features carry their manifest importance (0–600), below the
// named/unnamed gap so a rank never promotes an unnamed feature.
if (Number.isFinite(props.__gevRank))
score += Math.max(0, Math.min(600, props.__gevRank));
return score; return score;
} }
+1 -1
View File
@@ -85,7 +85,7 @@ test('Warendorf webcam display introduces the shared OSM credit', (t) => {
const v = viewer(t), empty = new Proxy({}, { get: () => noop }); const v = viewer(t), empty = new Proxy({}, { get: () => noop });
const lifecycle = createCctvLifecycle({ state: { _viewer: v, _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost }, const lifecycle = createCctvLifecycle({ state: { _viewer: v, _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost },
services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty }, services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty },
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty, navigation: empty }, source: {} }); parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty }, source: {} });
lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable(); lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable();
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0); assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
}); });
+140 -37
View File
@@ -301,8 +301,6 @@
"AVAILABLE MISSIONS": "المهام المتاحة", "AVAILABLE MISSIONS": "المهام المتاحة",
"SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها", "SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها",
"LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا", "LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا",
"Space Missions unavailable: the mission index could not load. Try again later.": "المهام الفضائية غير متاحة: تعذّر تحميل فهرس المهام. حاول مرة أخرى لاحقًا.",
"30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح",
"RADIO": "الراديو", "RADIO": "الراديو",
"ENABLE": "تفعيل", "ENABLE": "تفعيل",
"Previous filtered radio station": "محطة الراديو السابقة في القائمة المصفّاة", "Previous filtered radio station": "محطة الراديو السابقة في القائمة المصفّاة",
@@ -1749,11 +1747,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى", "TomTom roads first; OpenStreetMap fills the rest, simulated": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى (يحتاج إلى مفتاح TomTom)", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى (يحتاج إلى مفتاح TomTom)",
"Hybrid": "هجين", "Hybrid": "هجين",
"Lines": "خطوط",
"Dots": "نقاط",
"Road lines colored by live flow: green to red": "خطوط الطرق ملوّنة حسب التدفق المباشر: من الأخضر إلى الأحمر",
"Moving dots colored by live flow": "نقاط متحركة ملوّنة حسب التدفق المباشر",
"Moving dots": "نقاط متحركة",
"TomTom key unavailable": "مفتاح TomTom غير متاح", "TomTom key unavailable": "مفتاح TomTom غير متاح",
"TomTom daily budget reached": "بلغت الحصة اليومية لـ TomTom", "TomTom daily budget reached": "بلغت الحصة اليومية لـ TomTom",
"Monthly traffic allowance used": "تم استهلاك حصة حركة المرور الشهرية", "Monthly traffic allowance used": "تم استهلاك حصة حركة المرور الشهرية",
@@ -3233,7 +3226,7 @@
"Every detection — many of the strongest fires are rated low confidence": "كل عمليات الرصد — كثير من أقوى الحرائق مصنّفة بثقة منخفضة", "Every detection — many of the strongest fires are rated low confidence": "كل عمليات الرصد — كثير من أقوى الحرائق مصنّفة بثقة منخفضة",
"Nominal and high confidence detections": "عمليات الرصد ذات الثقة المتوسطة والعالية", "Nominal and high confidence detections": "عمليات الرصد ذات الثقة المتوسطة والعالية",
"High confidence detections only": "عمليات الرصد ذات الثقة العالية فقط", "High confidence detections only": "عمليات الرصد ذات الثقة العالية فقط",
"Fire intensity (FRP)": "شدة الحريق (FRP)", "Fire intensity (FRP)": "الشدة (FRP)",
"≥ {value} MW": "{value} MW على الأقل", "≥ {value} MW": "{value} MW على الأقل",
"Confidence": "الثقة", "Confidence": "الثقة",
"Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط", "Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط",
@@ -3251,15 +3244,15 @@
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة", "Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
"Voice place search (needs an Overpass server)": "البحث الصوتي عن الأماكن (يتطلب خادم Overpass)", "Voice place search (needs an Overpass server)": "البحث الصوتي عن الأماكن (يتطلب خادم Overpass)",
"OSM Places": "أماكن OSM", "OSM Places": "أماكن OSM",
"state or province": "ولاية أو إقليم", "state or province": "ولاية أو مقاطعة",
"county or district": "مقاطعة أو قضاء", "county or district": "محافظة أو قضاء",
"neighbourhood": "حي", "neighbourhood": "حي",
"natural region": "منطقة طبيعية", "natural region": "منطقة طبيعية",
"Which {place}?": "أي {place} تقصد؟", "Which {place}?": "أي {place} تقصد؟",
"Which place?": "أي مكان تقصد؟", "Which place?": "أي مكان تقصد؟",
"Not drawn: {parts}": "لم يُرسم المخطط: {parts}", "Not drawn: {parts}": "لم يُرسم: {parts}",
"approximate": "تقريبي", "approximate": "تقريبي",
"{pct}% cloud": "الغيوم {pct}%", "{pct}% cloud": "غيوم {pct}%",
"Over {area}": "فوق {area}", "Over {area}": "فوق {area}",
"{count} of {total} acquisitions match": { "{count} of {total} acquisitions match": {
"few": "الصور المطابقة: {count} من {total}", "few": "الصور المطابقة: {count} من {total}",
@@ -3270,12 +3263,12 @@
"other": "الصور المطابقة: {count} من {total}" "other": "الصور المطابقة: {count} من {total}"
}, },
"{n} acquisitions searched": { "{n} acquisitions searched": {
"zero": "الصور المفحوصة: {n}", "few": "الصور التي جرى البحث فيها: {n}",
"one": "الصور المفحوصة: {n}", "many": "الصور التي جرى البحث فيها: {n}",
"two": "الصور المفحوصة: {n}", "one": "الصور التي جرى البحث فيها: {n}",
"few": "الصور المفحوصة: {n}", "two": "الصور التي جرى البحث فيها: {n}",
"many": "الصور المفحوصة: {n}", "zero": "الصور التي جرى البحث فيها: {n}",
"other": "الصور المفحوصة: {n}" "other": "الصور التي جرى البحث فيها: {n}"
}, },
"No matching imagery": "لا توجد صور مطابقة", "No matching imagery": "لا توجد صور مطابقة",
"Unknown number of {kind}": "{kind}: العدد غير معروف", "Unknown number of {kind}": "{kind}: العدد غير معروف",
@@ -3283,10 +3276,10 @@
"At least {count} {kind}": "{kind}: {count} على الأقل", "At least {count} {kind}": "{kind}: {count} على الأقل",
"{count} {kind}": "{kind}: {count}", "{count} {kind}": "{kind}: {count}",
"In {area}": "في {area}", "In {area}": "في {area}",
"Area around {place}": "المنطقة حول {place}", "Area around {place}": "المنطقة المحيطة بـ{place}",
"Area around landmark": "المنطقة حول المعلم", "Area around landmark": "المنطقة المحيطة بمعلم",
"Outline {place}": "رسم مخطط {place}", "Outline {place}": "تحديد مخطط {place}",
"Outline area": "رسم مخطط منطقة", "Outline area": "تحديد مخطط منطقة",
"Find {place}": "البحث عن {place}", "Find {place}": "البحث عن {place}",
"Find area": "البحث عن منطقة", "Find area": "البحث عن منطقة",
"Find recent imagery": "البحث عن صور حديثة", "Find recent imagery": "البحث عن صور حديثة",
@@ -3329,9 +3322,9 @@
"zero": "التكرارات المدمجة: {count}" "zero": "التكرارات المدمجة: {count}"
}, },
"listed the first {limit}": "عُرض أول {limit}", "listed the first {limit}": "عُرض أول {limit}",
"counted over the bounding box of {area}, not its outline": "جرى العدّ ضمن المستطيل المحيط بمنطقة {area}، لا ضمن حدودها الفعلية", "counted over the bounding box of {area}, not its outline": "عُدّ ضمن المستطيل المحيط ({area})، لا ضمن الحدود",
"Search cancelled": "أُلغي البحث", "Search cancelled": "أُلغي البحث",
"Sea Marks": "العلامات البحرية", "Sea Marks": "علامات بحرية",
"Ski Pistes": "مسارات التزلج", "Ski Pistes": "مسارات التزلج",
"Fly to Tokyo": "انتقل إلى طوكيو", "Fly to Tokyo": "انتقل إلى طوكيو",
"Show live flights": "اعرض تتبّع الرحلات المباشر", "Show live flights": "اعرض تتبّع الرحلات المباشر",
@@ -3406,7 +3399,7 @@
"Too many chat requests. Try again in {seconds} s.": "طلبات دردشة كثيرة جدًا. أعد المحاولة بعد {seconds} ث.", "Too many chat requests. Try again in {seconds} s.": "طلبات دردشة كثيرة جدًا. أعد المحاولة بعد {seconds} ث.",
"The provider refused access for this key. Check the key's project, region or permissions.": "رفض المزوّد الوصول باستخدام هذا المفتاح. تحقّق من مشروع المفتاح أو منطقته أو أذوناته.", "The provider refused access for this key. Check the key's project, region or permissions.": "رفض المزوّد الوصول باستخدام هذا المفتاح. تحقّق من مشروع المفتاح أو منطقته أو أذوناته.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "نفد رصيد حساب المزوّد. تحقّق من الفوترة، أو اختر مزوّد ذكاء اصطناعي آخر في الإعدادات.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "نفد رصيد حساب المزوّد. تحقّق من الفوترة، أو اختر مزوّد ذكاء اصطناعي آخر في الإعدادات.",
"{lower} to {upper}": "من {lower} إلى {upper}", "{lower} to {upper}": "{lower} إلى {upper}",
"Up to {upper}": "حتى {upper}", "Up to {upper}": "حتى {upper}",
"From {lower}": "من {lower}", "From {lower}": "من {lower}",
"Informational only · not for flight planning": "للعلم فقط · لا يصلح لتخطيط الرحلات", "Informational only · not for flight planning": "للعلم فقط · لا يصلح لتخطيط الرحلات",
@@ -3414,7 +3407,7 @@
"Restricted airspace source unavailable": "مصدر المجال الجوي المقيّد غير متاح", "Restricted airspace source unavailable": "مصدر المجال الجوي المقيّد غير متاح",
"Prohibited (P)": "محظور (P)", "Prohibited (P)": "محظور (P)",
"Restricted (R)": "مقيّد (R)", "Restricted (R)": "مقيّد (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي المحظور والمقيّد ذو الاستخدام الخاص لدى FAA فقط، ولا يشمل UAS Facility Map ولا قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).", "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي الخاص المحظور والمقيّد لدى FAA فقط، ولا يشمل UAS Facility Map أو قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).",
"Prohibited": "محظور", "Prohibited": "محظور",
"Restricted": "مقيّد", "Restricted": "مقيّد",
"Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد", "Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد",
@@ -3474,15 +3467,125 @@
"iNaturalist": "iNaturalist", "iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "مساهمو iNaturalist · التراخيص لكل سجل", "iNaturalist contributors · licences per record": "مساهمو iNaturalist · التراخيص لكل سجل",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "مساهمو iNaturalist · Acartia {count} (بحر ساليش) · التراخيص لكل سجل", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "مساهمو iNaturalist · Acartia {count} (بحر ساليش) · التراخيص لكل سجل",
"{days}d": "{days}ي", "tiles unavailable": "البلاطات غير متاحة",
"All markers": "كل العلامات", "zoom in to load this layer": "كبّر لتحميل هذه الطبقة",
"Show markers of every age": "عرض العلامات من جميع الأعمار", "Zoom to layer": "تكبير إلى الطبقة",
"Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة", "Zoom to layer · fly to where this layer has data": "تكبير إلى الطبقة · الطيران إلى حيث توجد بيانات هذه الطبقة",
"event age filter|All": "الكل", "Remove from Data Layers": "إزالة من طبقات البيانات",
"Event age": "عمر الحدث", "Remove from Data Layers · add it again from the library": "إزالة من طبقات البيانات · أضِفها مجددًا من المكتبة",
"Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ", "Coverage outline": "حدود التغطية",
"Close camera preview": "إغلاق معاينة الكاميرا", "DRAPED": "على التضاريس",
"Refresh map still": "تحديث صورة الخريطة الثابتة", "Below 250 m": "أقل من 250 م",
"The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.", "250–500 m": "250–500 م",
"Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك." "500–1,000 m": "500–1,000 م",
"1,000–2,000 m": "1,000–2,000 م",
"2,000–3,000 m": "2,000–3,000 م",
"3,000 m and above": "3,000 م فأكثر",
"Exit cockpit to fly to a layer": "اخرج من قمرة القيادة للطيران إلى طبقة",
"Library": "المكتبة",
"Environment": "البيئة",
"Imagery": "الصور",
"Demographics": "التركيبة السكانية",
"DATASET": "مجموعة بيانات",
"TILES": "بلاطات",
"PACK": "حزمة",
"INCUBATING": "قيد الاحتضان",
"COMMUNITY": "مجتمعي",
"VERIFIED": "موثّق",
"ARCHIVED": "مؤرشف",
"Datasets": "مجموعات البيانات",
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
"{count} datasets": {
"zero": "{count} مجموعة بيانات",
"one": "{count} مجموعة بيانات",
"two": "{count} مجموعتا بيانات",
"few": "{count} مجموعات بيانات",
"many": "{count} مجموعة بيانات",
"other": "{count} مجموعة بيانات"
},
"{count} tile layers": {
"zero": "{count} طبقة بلاطات",
"one": "{count} طبقة بلاطات",
"two": "{count} طبقتا بلاطات",
"few": "{count} طبقات بلاطات",
"many": "{count} طبقة بلاطات",
"other": "{count} طبقة بلاطات"
},
"{count} packs": {
"zero": "{count} حزمة",
"one": "{count} حزمة",
"two": "{count} حزمتان",
"few": "{count} حزم",
"many": "{count} حزمة",
"other": "{count} حزمة"
},
"Themes": "المواضيع",
"Added": "المُضافة",
"{count} on the map": {
"zero": "{count} على الخريطة",
"one": "{count} على الخريطة",
"two": "{count} على الخريطة",
"few": "{count} على الخريطة",
"many": "{count} على الخريطة",
"other": "{count} على الخريطة"
},
"none yet": "لا شيء بعد",
"Map features": "معالم الخريطة",
"from {count} datasets": {
"zero": "من {count} مجموعة بيانات",
"one": "من {count} مجموعة بيانات",
"two": "من {count} مجموعتَي بيانات",
"few": "من {count} مجموعات بيانات",
"many": "من {count} مجموعة بيانات",
"other": "من {count} مجموعة بيانات"
},
"switch on a dataset to load features": "شغّل مجموعة بيانات لتحميل المعالم",
"Switched on by this instance · part of the CCTV layer": "مُفعّل من هذا المثيل · جزء من طبقة CCTV",
"Operator opt-in: set {env}=1 on the server": "تفعيل من المشغّل: اضبط {env}=1 على الخادم",
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "تفعيل من المشغّل: أضِف {id} إلى GEV_LIBRARY_ENABLE على الخادم",
"Turned off by this instance's operator": "أوقفه مشغّل هذا المثيل",
"Needs a provider key on the server": "يتطلب مفتاح مزوّد على الخادم",
"Needs this app's server · not available in a static build": "يتطلب خادم هذا التطبيق · غير متاح في إصدار ثابت",
"Checking what this instance serves…": "جارٍ التحقق مما يقدّمه هذا المثيل…",
"Regional": "إقليمي",
"Orthophoto": "صورة متعامدة",
"Satellite mosaic": "فسيفساء أقمار صناعية",
"Oblique": "مائلة",
"Historic imagery": "صور تاريخية",
"True orthophoto": "صورة متعامدة حقيقية",
"Type": "النوع",
"Resolution": "الدقة",
"Flown": "تاريخ التصوير",
"Updates": "التحديث",
"Ortho quality": "جودة التعامد",
"DATA LIBRARY": "مكتبة البيانات",
"Close data library": "إغلاق مكتبة البيانات",
"Data Library at a glance": "مكتبة البيانات في لمحة",
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "مصادر مباشرة تتجاوز الطبقات الأساسية. يُجلب كل منها من مزوّده ما دام مفعّلًا؛ ولا يُخزَّن شيء في هذا التطبيق. أضِف واحدًا وسيظهر في طبقات البيانات ضمن المكتبة.",
"Name, provider or country": "الاسم أو المزوّد أو البلد",
"Theme": "الموضوع",
"All themes": "كل المواضيع",
"Only what this instance serves": "فقط ما يقدّمه هذا المثيل",
"OPT-IN": "اختياري",
"KEY": "مفتاح",
"KEYLESS": "بلا مفتاح",
"ZOOM": "تكبير",
"Zoom to {name}": "تكبير إلى {name}",
"WORKING…": "جارٍ العمل…",
"ADD": "إضافة",
"Remove {name} from Data Layers": "إزالة {name} من طبقات البيانات",
"Add {name} to Data Layers": "إضافة {name} إلى طبقات البيانات",
"CONFIRM · REMOVE ALL {count}": {
"zero": "تأكيد · إزالة الكل ({count})",
"one": "تأكيد · إزالة {count}",
"two": "تأكيد · إزالة الكل ({count})",
"few": "تأكيد · إزالة الكل ({count})",
"many": "تأكيد · إزالة الكل ({count})",
"other": "تأكيد · إزالة الكل ({count})"
},
"REMOVE ALL": "إزالة الكل",
"Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات",
"Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات",
"Showing {shown} of {total}": "عرض {shown} من {total}",
"BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات"
} }
+114 -35
View File
@@ -760,8 +760,6 @@
"AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN", "AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN",
"SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN", "SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN",
"LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN", "LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN",
"Space Missions unavailable: the mission index could not load. Try again later.": "Raummissionen nicht verfügbar: Der Missionsindex konnte nicht geladen werden. Später erneut versuchen.",
"30-DAY MISSION INDEX UNAVAILABLE": "30-TAGE-MISSIONSINDEX NICHT VERFÜGBAR",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: VORSCHAU · ENTER / LEERTASTE: AUSWAHL", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: VORSCHAU · ENTER / LEERTASTE: AUSWAHL",
"Internet radio and local SDR": "Internetradio und lokales SDR", "Internet radio and local SDR": "Internetradio und lokales SDR",
"STATION TAG": "KATEGORIE", "STATION TAG": "KATEGORIE",
@@ -1667,11 +1665,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert", "TomTom roads first; OpenStreetMap fills the rest, simulated": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert (braucht einen TomTom-Schlüssel)", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert (braucht einen TomTom-Schlüssel)",
"Hybrid": "Hybrid", "Hybrid": "Hybrid",
"Lines": "Linien",
"Dots": "Punkte",
"Road lines colored by live flow: green to red": "Straßenlinien nach Live-Verkehrsfluss gefärbt: grün bis rot",
"Moving dots colored by live flow": "Bewegte Punkte nach Live-Verkehrsfluss gefärbt",
"Moving dots": "Bewegte Punkte",
"TomTom key unavailable": "TomTom-Schlüssel nicht verfügbar", "TomTom key unavailable": "TomTom-Schlüssel nicht verfügbar",
"TomTom daily budget reached": "TomTom-Tagesbudget erreicht", "TomTom daily budget reached": "TomTom-Tagesbudget erreicht",
"Monthly traffic allowance used": "Monatliches Verkehrskontingent aufgebraucht", "Monthly traffic allowance used": "Monatliches Verkehrskontingent aufgebraucht",
@@ -2925,7 +2918,7 @@
"From {lower}": "Ab {lower}", "From {lower}": "Ab {lower}",
"Informational only · not for flight planning": "Nur zur Information · nicht für die Flugplanung", "Informational only · not for flight planning": "Nur zur Information · nicht für die Flugplanung",
"Unnamed zone": "Unbenannte Zone", "Unnamed zone": "Unbenannte Zone",
"Restricted airspace source unavailable": "Quelle für Sperr- und Flugbeschränkungsgebiete nicht verfügbar", "Restricted airspace source unavailable": "Quelle für Sperrgebiete nicht verfügbar",
"Prohibited (P)": "Sperrgebiet (P)", "Prohibited (P)": "Sperrgebiet (P)",
"Restricted (R)": "Flugbeschränkungsgebiet (R)", "Restricted (R)": "Flugbeschränkungsgebiet (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Nur Sperr- und Flugbeschränkungsgebiete der FAA (Luftraum mit besonderer Nutzung), nicht die UAS Facility Map oder UAS-Flugbeschränkungen. Nur zur Information – kein Ersatz für offizielle UAS-Flugplanungstools (B4UFLY / LAANC).", "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Nur Sperr- und Flugbeschränkungsgebiete der FAA (Luftraum mit besonderer Nutzung), nicht die UAS Facility Map oder UAS-Flugbeschränkungen. Nur zur Information – kein Ersatz für offizielle UAS-Flugplanungstools (B4UFLY / LAANC).",
@@ -2962,13 +2955,13 @@
"fire confidence filter|All": "Alle", "fire confidence filter|All": "Alle",
"fire confidence filter|Nominal+": "Mittel+", "fire confidence filter|Nominal+": "Mittel+",
"fire confidence filter|High": "Hoch", "fire confidence filter|High": "Hoch",
"Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen – viele der stärksten Brände haben eine niedrige Konfidenz", "Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen — viele der stärksten Brände haben eine niedrige Konfidenz",
"Nominal and high confidence detections": "Erkennungen mit mittlerer und hoher Konfidenz", "Nominal and high confidence detections": "Erkennungen mit mittlerer und hoher Konfidenz",
"High confidence detections only": "Nur Erkennungen mit hoher Konfidenz", "High confidence detections only": "Nur Erkennungen mit hoher Konfidenz",
"Fire intensity (FRP)": "Intensität (FRP)", "Fire intensity (FRP)": "Intensität (FRP)",
"≥ {value} MW": "≥ {value} MW", "≥ {value} MW": "≥ {value} MW",
"Confidence": "Konfidenz", "Confidence": "Konfidenz",
"Minimum fire radiative power, megawatts": "Mindestwert der Strahlungsleistung des Brandes in Megawatt", "Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt",
"fire intensity filter|All": "Alle", "fire intensity filter|All": "Alle",
"Detection confidence": "Konfidenz der Erkennung", "Detection confidence": "Konfidenz der Erkennung",
"{shown} of {total} shown": "{shown} von {total} angezeigt", "{shown} of {total} shown": "{shown} von {total} angezeigt",
@@ -3008,9 +3001,9 @@
"{count} {kind}": "{kind}: {count}", "{count} {kind}": "{kind}: {count}",
"In {area}": "Gebiet: {area}", "In {area}": "Gebiet: {area}",
"Area around {place}": "Gebiet um {place}", "Area around {place}": "Gebiet um {place}",
"Area around landmark": "Gebiet um einen Ort", "Area around landmark": "Gebiet um ein Wahrzeichen",
"Outline {place}": "Umriss von {place} zeichnen", "Outline {place}": "{place} umreißen",
"Outline area": "Umriss zeichnen", "Outline area": "Gebiet umreißen",
"Find {place}": "{place} finden", "Find {place}": "{place} finden",
"Find area": "Gebiet finden", "Find area": "Gebiet finden",
"Find recent imagery": "Aktuelle Bilddaten finden", "Find recent imagery": "Aktuelle Bilddaten finden",
@@ -3021,27 +3014,27 @@
"Loading imagery": "Bilddaten werden geladen", "Loading imagery": "Bilddaten werden geladen",
"Searching OpenStreetMap": "OpenStreetMap wird durchsucht", "Searching OpenStreetMap": "OpenStreetMap wird durchsucht",
"voice area|Area": "Gebiet", "voice area|Area": "Gebiet",
"buffer sized to the place": "Puffer entsprechend der Ortsgröße", "buffer sized to the place": "Puffer in Größe des Ortes",
"enclosing mapped area": "umschließendes kartiertes Gebiet", "enclosing mapped area": "umschließendes kartiertes Gebiet",
"the map was cleared while looking it up": "die Zeichenfläche wurde während der Suche geleert", "the map was cleared while looking it up": "die Karte wurde während der Suche geleert",
"OpenStreetMap coverage varies by place": "die Abdeckung von OpenStreetMap ist je nach Ort unterschiedlich", "OpenStreetMap coverage varies by place": "Die Abdeckung von OpenStreetMap ist je nach Ort unterschiedlich",
"the catalog covers the last 30 days": "der Katalog umfasst die letzten 30 Tage", "the catalog covers the last 30 days": "der Katalog umfasst die letzten 30 Tage",
"there are more": "es gibt weitere", "there are more": "es gibt weitere",
"{radius} m around the landmark": "{radius} m um den Ort", "{radius} m around the landmark": "{radius} m um das Wahrzeichen",
"{count} parts": { "{count} parts": {
"one": "{count} Teil", "one": "Teile: {count}",
"other": "{count} Teile" "other": "Teile: {count}"
}, },
"{drawn} of {total} parts drawn": { "{drawn} of {total} parts drawn": {
"one": "{drawn} von {total} Teil gezeichnet", "one": "Gezeichnete Teile: {drawn} von {total}",
"other": "{drawn} von {total} Teilen gezeichnet" "other": "Gezeichnete Teile: {drawn} von {total}"
}, },
"{count} duplicate mappings merged": { "{count} duplicate mappings merged": {
"one": "{count} Duplikat zusammengeführt", "one": "Zusammengeführte Doppelkartierungen: {count}",
"other": "{count} Duplikate zusammengeführt" "other": "Zusammengeführte Doppelkartierungen: {count}"
}, },
"listed the first {limit}": "die ersten {limit} aufgelistet", "listed the first {limit}": "die ersten {limit} aufgelistet",
"counted over the bounding box of {area}, not its outline": "im umgebenden Rechteck von {area} gezählt, nicht innerhalb des Umrisses", "counted over the bounding box of {area}, not its outline": "über das umgebende Rechteck gezählt ({area}), nicht über den Umriss",
"Search cancelled": "Suche abgebrochen", "Search cancelled": "Suche abgebrochen",
"Sea Marks": "Seezeichen", "Sea Marks": "Seezeichen",
"Ski Pistes": "Skipisten", "Ski Pistes": "Skipisten",
@@ -3174,15 +3167,101 @@
"iNaturalist": "iNaturalist", "iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "iNaturalist-Beitragende · Lizenzen je Eintrag", "iNaturalist contributors · licences per record": "iNaturalist-Beitragende · Lizenzen je Eintrag",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist-Beitragende · Acartia {count} (Salish-See) · Lizenzen je Eintrag", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist-Beitragende · Acartia {count} (Salish-See) · Lizenzen je Eintrag",
"{days}d": "{days}T", "tiles unavailable": "Kacheln nicht verfügbar",
"All markers": "Alle Markierungen", "zoom in to load this layer": "hineinzoomen, um diese Ebene zu laden",
"Show markers of every age": "Markierungen jeden Alters anzeigen", "Zoom to layer": "Zur Ebene zoomen",
"Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben", "Zoom to layer · fly to where this layer has data": "Zur Ebene zoomen · dorthin fliegen, wo diese Ebene Daten hat",
"event age filter|All": "Alle", "Remove from Data Layers": "Aus Datenebenen entfernen",
"Event age": "Ereignisalter", "Remove from Data Layers · add it again from the library": "Aus Datenebenen entfernen · über die Bibliothek erneut hinzufügen",
"Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben", "Coverage outline": "Abdeckungsumriss",
"Close camera preview": "Kameravorschau schließen", "DRAPED": "DRAPIERT",
"Refresh map still": "Kartenstandbild aktualisieren", "Below 250 m": "Unter 250 m",
"The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.", "250–500 m": "250–500 m",
"Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht." "500–1,000 m": "500–1.000 m",
"1,000–2,000 m": "1.000–2.000 m",
"2,000–3,000 m": "2.000–3.000 m",
"3,000 m and above": "3.000 m und mehr",
"Exit cockpit to fly to a layer": "Cockpit verlassen, um zu einer Ebene zu fliegen",
"Library": "Bibliothek",
"Environment": "Umwelt",
"Imagery": "Bildmaterial",
"Demographics": "Demografie",
"DATASET": "DATENSATZ",
"TILES": "KACHELN",
"PACK": "PAKET",
"INCUBATING": "IN ERPROBUNG",
"COMMUNITY": "COMMUNITY",
"VERIFIED": "GEPRÜFT",
"ARCHIVED": "ARCHIVIERT",
"Datasets": "Datensätze",
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
"{count} datasets": {
"one": "{count} Datensatz",
"other": "{count} Datensätze"
},
"{count} tile layers": {
"one": "{count} Kachelebene",
"other": "{count} Kachelebenen"
},
"{count} packs": {
"one": "{count} Paket",
"other": "{count} Pakete"
},
"Themes": "Themen",
"Added": "Hinzugefügt",
"{count} on the map": {
"one": "{count} auf der Karte",
"other": "{count} auf der Karte"
},
"none yet": "noch keine",
"Map features": "Kartenobjekte",
"from {count} datasets": {
"one": "aus {count} Datensatz",
"other": "aus {count} Datensätzen"
},
"switch on a dataset to load features": "einen Datensatz einschalten, um Objekte zu laden",
"Switched on by this instance · part of the CCTV layer": "Von dieser Instanz eingeschaltet · Teil der CCTV-Ebene",
"Operator opt-in: set {env}=1 on the server": "Opt-in durch Betreiber: {env}=1 auf dem Server setzen",
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Opt-in durch Betreiber: {id} zu GEV_LIBRARY_ENABLE auf dem Server hinzufügen",
"Turned off by this instance's operator": "Vom Betreiber dieser Instanz ausgeschaltet",
"Needs a provider key on the server": "Benötigt einen Anbieterschlüssel auf dem Server",
"Needs this app's server · not available in a static build": "Benötigt den Server dieser App · in einem statischen Build nicht verfügbar",
"Checking what this instance serves…": "Prüfe, was diese Instanz bereitstellt…",
"Regional": "Regional",
"Orthophoto": "Orthofoto",
"Satellite mosaic": "Satellitenmosaik",
"Oblique": "Schrägluftbild",
"Historic imagery": "Historische Luftbilder",
"True orthophoto": "True Orthofoto",
"Type": "Typ",
"Resolution": "Auflösung",
"Flown": "Befliegung",
"Updates": "Aktualisierung",
"Ortho quality": "Orthoqualität",
"DATA LIBRARY": "DATENBIBLIOTHEK",
"Close data library": "Datenbibliothek schließen",
"Data Library at a glance": "Datenbibliothek auf einen Blick",
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Live-Quellen über die Kernebenen hinaus. Jede wird von ihrem Anbieter abgerufen, solange sie eingeschaltet ist; in dieser App wird nichts gespeichert. Fügen Sie eine hinzu, und sie erscheint in den Datenebenen unter Bibliothek.",
"Name, provider or country": "Name, Anbieter oder Land",
"Theme": "Thema",
"All themes": "Alle Themen",
"Only what this instance serves": "Nur was diese Instanz bereitstellt",
"OPT-IN": "OPT-IN",
"KEY": "SCHLÜSSEL",
"KEYLESS": "OHNE SCHLÜSSEL",
"ZOOM": "ZOOMEN",
"Zoom to {name}": "Zu {name} zoomen",
"WORKING…": "ARBEITET…",
"ADD": "HINZUFÜGEN",
"Remove {name} from Data Layers": "{name} aus Datenebenen entfernen",
"Add {name} to Data Layers": "{name} zu Datenebenen hinzufügen",
"CONFIRM · REMOVE ALL {count}": {
"one": "BESTÄTIGEN · {count} ENTFERNEN",
"other": "BESTÄTIGEN · ALLE {count} ENTFERNEN"
},
"REMOVE ALL": "ALLE ENTFERNEN",
"Confirm: remove every added entry from Data Layers": "Bestätigen: alle hinzugefügten Einträge aus Datenebenen entfernen",
"Remove every added entry from Data Layers": "Alle hinzugefügten Einträge aus Datenebenen entfernen",
"Showing {shown} of {total}": "{shown} von {total} angezeigt",
"BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN"
} }
+117 -32
View File
@@ -192,8 +192,6 @@
"AVAILABLE MISSIONS": "MISIONES DISPONIBLES", "AVAILABLE MISSIONS": "MISIONES DISPONIBLES",
"SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR", "SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR",
"LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS", "LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS",
"Space Missions unavailable: the mission index could not load. Try again later.": "Misiones espaciales no disponibles: no se pudo cargar el índice de misiones. Inténtalo más tarde.",
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISIONES DE 30 DÍAS NO DISPONIBLE",
"RADIO": "RADIO", "RADIO": "RADIO",
"ENABLE": "ACTIVAR", "ENABLE": "ACTIVAR",
"Previous filtered radio station": "Emisora de radio filtrada anterior", "Previous filtered radio station": "Emisora de radio filtrada anterior",
@@ -2699,11 +2697,6 @@
"Zoom in for vector tile coverage": "Acércate para cargar las teselas vectoriales", "Zoom in for vector tile coverage": "Acércate para cargar las teselas vectoriales",
"Camera coverage unavailable": "Cobertura de cámaras no disponible", "Camera coverage unavailable": "Cobertura de cámaras no disponible",
"Hybrid": "Híbrido", "Hybrid": "Híbrido",
"Lines": "Líneas",
"Dots": "Puntos",
"Road lines colored by live flow: green to red": "Líneas de vías coloreadas por el flujo en vivo: de verde a rojo",
"Moving dots colored by live flow": "Puntos en movimiento coloreados por el flujo en vivo",
"Moving dots": "Puntos en movimiento",
"TomTom roads only, each with its live flow": "Solo vías de TomTom, cada una con su flujo en vivo", "TomTom roads only, each with its live flow": "Solo vías de TomTom, cada una con su flujo en vivo",
"TomTom roads only, each with its live flow (needs a TomTom key)": "Solo vías de TomTom, cada una con su flujo en vivo (necesita una clave de TomTom)", "TomTom roads only, each with its live flow (needs a TomTom key)": "Solo vías de TomTom, cada una con su flujo en vivo (necesita una clave de TomTom)",
"OpenStreetMap roads, with TomTom flow matched when available": "Vías de OpenStreetMap, con el flujo de TomTom emparejado cuando está disponible", "OpenStreetMap roads, with TomTom flow matched when available": "Vías de OpenStreetMap, con el flujo de TomTom emparejado cuando está disponible",
@@ -2999,7 +2992,7 @@
"fire confidence filter|All": "Todas", "fire confidence filter|All": "Todas",
"fire confidence filter|Nominal+": "Nominal+", "fire confidence filter|Nominal+": "Nominal+",
"fire confidence filter|High": "Alta", "fire confidence filter|High": "Alta",
"Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos se clasifican con confianza baja", "Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos tienen confianza baja",
"Nominal and high confidence detections": "Detecciones de confianza nominal y alta", "Nominal and high confidence detections": "Detecciones de confianza nominal y alta",
"High confidence detections only": "Solo detecciones de confianza alta", "High confidence detections only": "Solo detecciones de confianza alta",
"Fire intensity (FRP)": "Intensidad (FRP)", "Fire intensity (FRP)": "Intensidad (FRP)",
@@ -3008,8 +3001,8 @@
"Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios", "Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios",
"fire intensity filter|All": "Todo", "fire intensity filter|All": "Todo",
"Detection confidence": "Confianza de la detección", "Detection confidence": "Confianza de la detección",
"{shown} of {total} shown": "Mostrando {shown} de {total}", "{shown} of {total} shown": "{shown} de {total} mostrados",
"{lower} to {upper}": "De {lower} a {upper}", "{lower} to {upper}": "{lower} a {upper}",
"Up to {upper}": "Hasta {upper}", "Up to {upper}": "Hasta {upper}",
"From {lower}": "Desde {lower}", "From {lower}": "Desde {lower}",
"Informational only · not for flight planning": "Solo informativo · no apto para planificar vuelos", "Informational only · not for flight planning": "Solo informativo · no apto para planificar vuelos",
@@ -3017,9 +3010,9 @@
"Restricted airspace source unavailable": "Fuente de espacio aéreo restringido no disponible", "Restricted airspace source unavailable": "Fuente de espacio aéreo restringido no disponible",
"Prohibited (P)": "Prohibido (P)", "Prohibited (P)": "Prohibido (P)",
"Restricted (R)": "Restringido (R)", "Restricted (R)": "Restringido (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo espacio aéreo de uso especial prohibido y restringido de la FAA; no incluye el UAS Facility Map ni las restricciones de vuelo de UAS. Solo informativo — no sustituye a las herramientas oficiales de planificación de vuelos de UAS (B4UFLY / LAANC).", "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo espacio aéreo de uso especial prohibido y restringido de la FAA, sin el UAS Facility Map ni las restricciones de vuelo de UAS. Solo informativo: no sustituye a las herramientas oficiales de planificación de vuelos de UAS (B4UFLY / LAANC).",
"Prohibited": "Zona prohibida", "Prohibited": "Prohibido",
"Restricted": "Zona restringida", "Restricted": "Restringido",
"Prohibited & Restricted Airspace": "Espacio aéreo prohibido y restringido", "Prohibited & Restricted Airspace": "Espacio aéreo prohibido y restringido",
"routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez", "routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez",
"route proxy error": "error del proxy de rutas", "route proxy error": "error del proxy de rutas",
@@ -3057,11 +3050,11 @@
"county or district": "condado o distrito", "county or district": "condado o distrito",
"neighbourhood": "barrio", "neighbourhood": "barrio",
"natural region": "región natural", "natural region": "región natural",
"Which {place}?": "¿Qué {place}?", "Which {place}?": "¿Cuál {place}?",
"Which place?": "¿Qué lugar?", "Which place?": "¿Qué lugar?",
"Not drawn: {parts}": "Sin dibujar: {parts}", "Not drawn: {parts}": "Sin dibujar: {parts}",
"approximate": "aproximada", "approximate": "aproximado",
"{pct}% cloud": "{pct}% de nubes", "{pct}% cloud": "{pct} % de nubes",
"Over {area}": "Zona: {area}", "Over {area}": "Zona: {area}",
"{count} of {total} acquisitions match": { "{count} of {total} acquisitions match": {
"many": "Adquisiciones coincidentes: {count} de {total}", "many": "Adquisiciones coincidentes: {count} de {total}",
@@ -3075,7 +3068,7 @@
}, },
"No matching imagery": "No hay imágenes que coincidan", "No matching imagery": "No hay imágenes que coincidan",
"Unknown number of {kind}": "{kind}: número desconocido", "Unknown number of {kind}": "{kind}: número desconocido",
"About {count} {kind}": "{kind}: aproximadamente {count}", "About {count} {kind}": "{kind}: unos {count}",
"At least {count} {kind}": "{kind}: al menos {count}", "At least {count} {kind}": "{kind}: al menos {count}",
"{count} {kind}": "{kind}: {count}", "{count} {kind}": "{kind}: {count}",
"In {area}": "En {area}", "In {area}": "En {area}",
@@ -3094,12 +3087,12 @@
"Searching OpenStreetMap": "Buscando en OpenStreetMap", "Searching OpenStreetMap": "Buscando en OpenStreetMap",
"voice area|Area": "Área", "voice area|Area": "Área",
"buffer sized to the place": "zona aproximada del tamaño del lugar", "buffer sized to the place": "zona aproximada del tamaño del lugar",
"enclosing mapped area": "zona mapeada que contiene el punto de interés", "enclosing mapped area": "zona cartografiada que lo rodea",
"the map was cleared while looking it up": "el mapa se borró durante la búsqueda", "the map was cleared while looking it up": "el mapa se borró durante la búsqueda",
"OpenStreetMap coverage varies by place": "la cobertura de OpenStreetMap varía según el lugar", "OpenStreetMap coverage varies by place": "La cobertura de OpenStreetMap varía según el lugar",
"the catalog covers the last 30 days": "el catálogo cubre los últimos 30 días", "the catalog covers the last 30 days": "el catálogo cubre los últimos 30 días",
"there are more": "hay más", "there are more": "hay más",
"{radius} m around the landmark": "{radius} m alrededor del punto de interés", "{radius} m around the landmark": "{radius} m alrededor del lugar",
"{count} parts": { "{count} parts": {
"many": "Partes: {count}", "many": "Partes: {count}",
"one": "Partes: {count}", "one": "Partes: {count}",
@@ -3116,7 +3109,7 @@
"other": "Duplicados fusionados: {count}" "other": "Duplicados fusionados: {count}"
}, },
"listed the first {limit}": "se listaron los primeros {limit}", "listed the first {limit}": "se listaron los primeros {limit}",
"counted over the bounding box of {area}, not its outline": "se contó en el rectángulo que abarca {area}, no dentro de su contorno", "counted over the bounding box of {area}, not its outline": "contado en el rectángulo que lo rodea ({area}), no en su contorno",
"Search cancelled": "Búsqueda cancelada", "Search cancelled": "Búsqueda cancelada",
"Sea Marks": "Señales marítimas", "Sea Marks": "Señales marítimas",
"Ski Pistes": "Pistas de esquí", "Ski Pistes": "Pistas de esquí",
@@ -3249,15 +3242,107 @@
"iNaturalist": "iNaturalist", "iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "Colaboradores de iNaturalist · licencias por registro", "iNaturalist contributors · licences per record": "Colaboradores de iNaturalist · licencias por registro",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores de iNaturalist · Acartia {count} (mar de Salish) · licencias por registro", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores de iNaturalist · Acartia {count} (mar de Salish) · licencias por registro",
"{days}d": "{days}d", "tiles unavailable": "teselas no disponibles",
"All markers": "Todos los marcadores", "Environment": "Medio ambiente",
"Show markers of every age": "Mostrar marcadores de cualquier antigüedad", "Imagery": "Imágenes",
"Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha", "DATASET": "DATOS",
"event age filter|All": "Todos", "TILES": "TESELAS",
"Event age": "Antigüedad", "PACK": "PAQUETE",
"Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha", "INCUBATING": "EN INCUBACIÓN",
"Close camera preview": "Cerrar vista previa de la cámara", "COMMUNITY": "COMUNIDAD",
"Refresh map still": "Actualizar imagen fija del mapa", "VERIFIED": "VERIFICADO",
"The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.", "ARCHIVED": "ARCHIVADO",
"Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP." "Switched on by this instance · part of the CCTV layer": "Activado en esta instancia · parte de la capa CCTV",
"Operator opt-in: set {env}=1 on the server": "Activación del operador: define {env}=1 en el servidor",
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Activación del operador: añade {id} a GEV_LIBRARY_ENABLE en el servidor",
"Turned off by this instance's operator": "Desactivado por el operador de esta instancia",
"Needs a provider key on the server": "Necesita una clave del proveedor en el servidor",
"Needs this app's server · not available in a static build": "Necesita el servidor de esta app · no disponible en una compilación estática",
"Checking what this instance serves…": "Comprobando qué ofrece esta instancia…",
"Regional": "Regional",
"DATA LIBRARY": "BIBLIOTECA DE DATOS",
"Close data library": "Cerrar la biblioteca de datos",
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fuentes en vivo más allá de las capas principales. Cada una se obtiene de su proveedor mientras está activa; esta app no almacena nada. Añade una y aparecerá en Capas de datos, en Biblioteca.",
"Name, provider or country": "Nombre, proveedor o país",
"Theme": "Tema",
"All themes": "Todos los temas",
"Only what this instance serves": "Solo lo que ofrece esta instancia",
"OPT-IN": "OPCIONAL",
"KEY": "CLAVE",
"KEYLESS": "SIN CLAVE",
"WORKING…": "PROCESANDO…",
"ADD": "AÑADIR",
"Remove {name} from Data Layers": "Quitar {name} de Capas de datos",
"Add {name} to Data Layers": "Añadir {name} a Capas de datos",
"Library": "Biblioteca",
"BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DATOS",
"DRAPED": "SUPERPUESTO",
"Zoom to layer": "Acercar a la capa",
"ZOOM": "ACERCAR",
"Zoom to {name}": "Acercar a {name}",
"CONFIRM · REMOVE ALL {count}": {
"one": "CONFIRMAR · QUITAR {count}",
"many": "CONFIRMAR · QUITAR LAS {count}",
"other": "CONFIRMAR · QUITAR LAS {count}"
},
"REMOVE ALL": "QUITAR TODO",
"Confirm: remove every added entry from Data Layers": "Confirmar: quitar de Capas de datos todas las entradas añadidas",
"Remove every added entry from Data Layers": "Quitar de Capas de datos todas las entradas añadidas",
"Exit cockpit to fly to a layer": "Sal de la cabina para volar a una capa",
"Zoom to layer · fly to where this layer has data": "Acercar a la capa · vuela a donde esta capa tiene datos",
"Remove from Data Layers": "Quitar de Capas de datos",
"Remove from Data Layers · add it again from the library": "Quitar de Capas de datos · vuelve a añadirla desde la biblioteca",
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
"{count} datasets": {
"one": "{count} conjunto de datos",
"many": "{count} de conjuntos de datos",
"other": "{count} conjuntos de datos"
},
"{count} tile layers": {
"one": "{count} capa de teselas",
"many": "{count} de capas de teselas",
"other": "{count} capas de teselas"
},
"{count} packs": {
"one": "{count} paquete",
"many": "{count} de paquetes",
"other": "{count} paquetes"
},
"Added": "Añadidas",
"{count} on the map": {
"one": "{count} en el mapa",
"many": "{count} en el mapa",
"other": "{count} en el mapa"
},
"none yet": "ninguna todavía",
"from {count} datasets": {
"one": "de {count} conjunto de datos",
"many": "de {count} de conjuntos de datos",
"other": "de {count} conjuntos de datos"
},
"switch on a dataset to load features": "activa un conjunto de datos para cargar elementos",
"Data Library at a glance": "La biblioteca de datos de un vistazo",
"Map features": "Elementos del mapa",
"Showing {shown} of {total}": "Mostrando {shown} de {total}",
"Datasets": "Conjuntos de datos",
"Themes": "Temas",
"zoom in to load this layer": "acércate para cargar esta capa",
"Demographics": "Demografía",
"Coverage outline": "Contorno de cobertura",
"Orthophoto": "Ortofoto",
"Satellite mosaic": "Mosaico satelital",
"Oblique": "Oblicua",
"Historic imagery": "Imágenes históricas",
"True orthophoto": "Ortofoto verdadera",
"Type": "Tipo",
"Resolution": "Resolución",
"Flown": "Vuelo",
"Updates": "Actualización",
"Ortho quality": "Calidad del orto",
"Below 250 m": "Menos de 250 m",
"250–500 m": "250–500 m",
"500–1,000 m": "500–1000 m",
"1,000–2,000 m": "1000–2000 m",
"2,000–3,000 m": "2000–3000 m",
"3,000 m and above": "3000 m o más"
} }
+130 -45
View File
@@ -1103,11 +1103,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation", "TomTom roads first; OpenStreetMap fills the rest, simulated": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation (nécessite une clé TomTom)", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation (nécessite une clé TomTom)",
"Hybrid": "Hybride", "Hybrid": "Hybride",
"Lines": "Lignes",
"Dots": "Points",
"Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge",
"Moving dots colored by live flow": "Points mobiles colorés selon le trafic en direct",
"Moving dots": "Points mobiles",
"TomTom key unavailable": "Clé TomTom indisponible", "TomTom key unavailable": "Clé TomTom indisponible",
"TomTom daily budget reached": "Quota quotidien TomTom atteint", "TomTom daily budget reached": "Quota quotidien TomTom atteint",
"Monthly traffic allowance used": "Quota mensuel de trafic épuisé", "Monthly traffic allowance used": "Quota mensuel de trafic épuisé",
@@ -2810,8 +2805,6 @@
"AVAILABLE MISSIONS": "MISSIONS DISPONIBLES", "AVAILABLE MISSIONS": "MISSIONS DISPONIBLES",
"SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER", "SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER",
"LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS", "LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.",
"30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE",
"Internet radio and local SDR": "Radio Internet et SDR local", "Internet radio and local SDR": "Radio Internet et SDR local",
"STATION TAG": "TAG DE STATION", "STATION TAG": "TAG DE STATION",
@@ -2990,18 +2983,18 @@
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.", "Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.",
"Malformed cyclone snapshot": "Instantané cyclonique mal formé", "Malformed cyclone snapshot": "Instantané cyclonique mal formé",
"Cyclone request timed out": "Délai dépassé pour la requête cyclonique", "Cyclone request timed out": "Délai dépassé pour la requête cyclonique",
"{lower} to {upper}": "De {lower} à {upper}", "{lower} to {upper}": "{lower} à {upper}",
"Up to {upper}": "Jusqu'à {upper}", "Up to {upper}": "Jusqu’à {upper}",
"From {lower}": "À partir de {lower}", "From {lower}": "À partir de {lower}",
"Informational only · not for flight planning": "À titre indicatif · ne pas utiliser pour la préparation des vols", "Informational only · not for flight planning": "À titre informatif uniquement · ne pas utiliser pour la planification de vol",
"Unnamed zone": "Sans nom", "Unnamed zone": "Zone sans nom",
"Restricted airspace source unavailable": "Source de l'espace aérien réglementé indisponible", "Restricted airspace source unavailable": "Source de l’espace aérien réglementé indisponible",
"Prohibited (P)": "Zone interdite (P)", "Prohibited (P)": "Zone interdite (P)",
"Restricted (R)": "Zone réglementée (R)", "Restricted (R)": "Zone réglementée (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Uniquement les zones interdites et réglementées de la FAA (espace aérien à usage spécial), sans la UAS Facility Map ni les restrictions de vol des drones. À titre indicatif uniquement — ne remplace pas les outils officiels de préparation des vols de drones (B4UFLY / LAANC).", "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Uniquement les zones interdites et réglementées de la FAA (espace aérien à usage spécial), sans la UAS Facility Map ni les restrictions de vol des drones. À titre informatif uniquement — ne remplace pas les outils officiels de planification de vol des drones (B4UFLY / LAANC).",
"Prohibited": "Zone interdite", "Prohibited": "Zone interdite",
"Restricted": "Zone réglementée", "Restricted": "Zone réglementée",
"Prohibited & Restricted Airspace": "Espaces aériens interdits et réglementés", "Prohibited & Restricted Airspace": "Zones interdites et réglementées",
"scene|PLAY": "LIRE", "scene|PLAY": "LIRE",
"AUTHORING": "CRÉATION", "AUTHORING": "CRÉATION",
"Create and edit scenes": "Créer et modifier des scènes", "Create and edit scenes": "Créer et modifier des scènes",
@@ -3032,7 +3025,7 @@
"fire confidence filter|All": "Toutes", "fire confidence filter|All": "Toutes",
"fire confidence filter|Nominal+": "Nominale+", "fire confidence filter|Nominal+": "Nominale+",
"fire confidence filter|High": "Élevée", "fire confidence filter|High": "Élevée",
"Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — de nombreux feux parmi les plus intenses sont classés en confiance faible", "Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — beaucoup des feux les plus intenses ont une confiance faible",
"Nominal and high confidence detections": "Détections de confiance nominale et élevée", "Nominal and high confidence detections": "Détections de confiance nominale et élevée",
"High confidence detections only": "Détections de confiance élevée uniquement", "High confidence detections only": "Détections de confiance élevée uniquement",
"Fire intensity (FRP)": "Intensité (FRP)", "Fire intensity (FRP)": "Intensité (FRP)",
@@ -3041,7 +3034,7 @@
"Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts", "Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts",
"fire intensity filter|All": "Toutes", "fire intensity filter|All": "Toutes",
"Detection confidence": "Confiance de la détection", "Detection confidence": "Confiance de la détection",
"{shown} of {total} shown": "Affichés : {shown} sur {total}", "{shown} of {total} shown": "{shown} sur {total} affichées",
"Name of {name}": "Nom de {name}", "Name of {name}": "Nom de {name}",
"Change the colour of {name}": "Changer la couleur de {name}", "Change the colour of {name}": "Changer la couleur de {name}",
"Colour of {name}": "Couleur de {name}", "Colour of {name}": "Couleur de {name}",
@@ -3051,7 +3044,7 @@
"UTILITIES": "OUTILS", "UTILITIES": "OUTILS",
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin", "Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers", "Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
"Voice place search (needs an Overpass server)": "Recherche vocale de lieux (nécessite un serveur Overpass)", "Voice place search (needs an Overpass server)": "Recherche de lieux par la voix (nécessite un serveur Overpass)",
"OSM Places": "Lieux OSM", "OSM Places": "Lieux OSM",
"state or province": "État ou province", "state or province": "État ou province",
"county or district": "comté ou district", "county or district": "comté ou district",
@@ -3059,9 +3052,9 @@
"natural region": "région naturelle", "natural region": "région naturelle",
"Which {place}?": "Quel lieu nommé {place} ?", "Which {place}?": "Quel lieu nommé {place} ?",
"Which place?": "Quel lieu ?", "Which place?": "Quel lieu ?",
"Not drawn: {parts}": "Contour non tracé : {parts}", "Not drawn: {parts}": "Non dessiné : {parts}",
"approximate": "approximatif", "approximate": "approximatif",
"{pct}% cloud": "{pct} % de nuages", "{pct}% cloud": "{pct} % de nuages",
"Over {area}": "Zone : {area}", "Over {area}": "Zone : {area}",
"{count} of {total} acquisitions match": { "{count} of {total} acquisitions match": {
"many": "Acquisitions correspondantes : {count} sur {total}", "many": "Acquisitions correspondantes : {count} sur {total}",
@@ -3080,43 +3073,43 @@
"{count} {kind}": "{kind} : {count}", "{count} {kind}": "{kind} : {count}",
"In {area}": "Zone : {area}", "In {area}": "Zone : {area}",
"Area around {place}": "Environs : {place}", "Area around {place}": "Environs : {place}",
"Area around landmark": "Environs d'un lieu", "Area around landmark": "Environs d'un repère",
"Outline {place}": "Délimiter {place}", "Outline {place}": "Délimiter {place}",
"Outline area": "Délimiter une zone", "Outline area": "Délimiter une zone",
"Find {place}": "Trouver {place}", "Find {place}": "Trouver {place}",
"Find area": "Trouver une zone", "Find area": "Trouver une zone",
"Find recent imagery": "Trouver des images récentes", "Find recent imagery": "Trouver des images récentes",
"Find {what}": "Trouver : {what}", "Find {what}": "Trouver {what}",
"Find places": "Trouver des lieux", "Find places": "Trouver des lieux",
"Drawing outline": "Dessin du contour", "Drawing outline": "Dessin du contour",
"Searching imagery catalog": "Recherche dans le catalogue d'images", "Searching imagery catalog": "Recherche dans le catalogue d'images",
"Loading imagery": "Chargement des images", "Loading imagery": "Chargement des images",
"Searching OpenStreetMap": "Recherche dans OpenStreetMap", "Searching OpenStreetMap": "Recherche dans OpenStreetMap",
"voice area|Area": "Zone", "voice area|Area": "Zone",
"buffer sized to the place": "zone tampon adaptée à la taille du lieu", "buffer sized to the place": "zone tampon à la taille du lieu",
"enclosing mapped area": "zone cartographiée englobante", "enclosing mapped area": "zone cartographiée englobante",
"the map was cleared while looking it up": "la carte a été effacée pendant la recherche", "the map was cleared while looking it up": "la carte a été effacée pendant la recherche",
"OpenStreetMap coverage varies by place": "la couverture OpenStreetMap varie selon les lieux", "OpenStreetMap coverage varies by place": "La couverture OpenStreetMap varie selon les lieux",
"the catalog covers the last 30 days": "le catalogue couvre les 30 derniers jours", "the catalog covers the last 30 days": "le catalogue couvre les 30 derniers jours",
"there are more": "il y en a d'autres", "there are more": "il y en a d'autres",
"{radius} m around the landmark": "{radius} m autour du lieu", "{radius} m around the landmark": "{radius} m autour du lieu",
"{count} parts": { "{count} parts": {
"many": "Parties : {count}", "many": "Parties : {count}",
"one": "Parties : {count}", "one": "Parties : {count}",
"other": "Parties : {count}" "other": "Parties : {count}"
}, },
"{drawn} of {total} parts drawn": { "{drawn} of {total} parts drawn": {
"many": "Parties tracées : {drawn} sur {total}", "many": "Parties tracées : {drawn} sur {total}",
"one": "Parties tracées : {drawn} sur {total}", "one": "Parties tracées : {drawn} sur {total}",
"other": "Parties tracées : {drawn} sur {total}" "other": "Parties tracées : {drawn} sur {total}"
}, },
"{count} duplicate mappings merged": { "{count} duplicate mappings merged": {
"many": "Doublons fusionnés : {count}", "many": "Doublons fusionnés : {count}",
"one": "Doublons fusionnés : {count}", "one": "Doublons fusionnés : {count}",
"other": "Doublons fusionnés : {count}" "other": "Doublons fusionnés : {count}"
}, },
"listed the first {limit}": "liste limitée à {limit}", "listed the first {limit}": "les {limit} premiers sont listés",
"counted over the bounding box of {area}, not its outline": "compté dans le rectangle englobant ({area}), pas dans son contour exact", "counted over the bounding box of {area}, not its outline": "compté sur le rectangle englobant ({area}), pas sur le contour",
"Search cancelled": "Recherche annulée", "Search cancelled": "Recherche annulée",
"Sea Marks": "Balisage maritime", "Sea Marks": "Balisage maritime",
"Ski Pistes": "Pistes de ski", "Ski Pistes": "Pistes de ski",
@@ -3249,15 +3242,107 @@
"iNaturalist": "iNaturalist", "iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation", "iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation",
"{days}d": "{days}j", "tiles unavailable": "tuiles indisponibles",
"All markers": "Tous les marqueurs", "zoom in to load this layer": "zoomez pour charger cette couche",
"Show markers of every age": "Afficher les marqueurs de tout âge", "Zoom to layer": "Zoomer sur la couche",
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés", "Zoom to layer · fly to where this layer has data": "Zoomer sur la couche · voler là où cette couche a des données",
"event age filter|All": "Tous", "Remove from Data Layers": "Retirer des Couches de données",
"Event age": "Ancienneté", "Remove from Data Layers · add it again from the library": "Retirer des Couches de données · ajoutez-la à nouveau depuis la bibliothèque",
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés", "Coverage outline": "Contour de couverture",
"Close camera preview": "Fermer l’aperçu de la caméra", "DRAPED": "DRAPÉ",
"Refresh map still": "Actualiser l’image fixe sur la carte", "Below 250 m": "Moins de 250 m",
"The map still uses a separate request and may show a different moment.": "L’image fixe de la carte utilise une requête distincte et peut montrer un autre moment.", "250–500 m": "250–500 m",
"Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP." "500–1,000 m": "500–1 000 m",
"1,000–2,000 m": "1 000–2 000 m",
"2,000–3,000 m": "2 000–3 000 m",
"3,000 m and above": "3 000 m et plus",
"Exit cockpit to fly to a layer": "Quittez le cockpit pour voler vers une couche",
"Library": "Bibliothèque",
"Environment": "Environnement",
"Imagery": "Imagerie",
"Demographics": "Démographie",
"DATASET": "JEU DE DONNÉES",
"TILES": "TUILES",
"PACK": "PACK",
"INCUBATING": "EN INCUBATION",
"COMMUNITY": "COMMUNAUTÉ",
"VERIFIED": "VÉRIFIÉ",
"ARCHIVED": "ARCHIVÉ",
"Datasets": "Jeux de données",
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
"{count} datasets": {
"one": "{count} jeu de données",
"many": "{count} de jeux de données",
"other": "{count} jeux de données"
},
"{count} tile layers": {
"one": "{count} couche de tuiles",
"many": "{count} de couches de tuiles",
"other": "{count} couches de tuiles"
},
"{count} packs": {
"one": "{count} pack",
"many": "{count} de packs",
"other": "{count} packs"
},
"Themes": "Thèmes",
"Added": "Ajoutées",
"{count} on the map": {
"one": "{count} sur la carte",
"many": "{count} sur la carte",
"other": "{count} sur la carte"
},
"none yet": "aucune pour l'instant",
"Map features": "Entités cartographiques",
"from {count} datasets": {
"one": "issues de {count} jeu de données",
"many": "issues de {count} de jeux de données",
"other": "issues de {count} jeux de données"
},
"switch on a dataset to load features": "activez un jeu de données pour charger des entités",
"Switched on by this instance · part of the CCTV layer": "Activé par cette instance · fait partie de la couche CCTV",
"Operator opt-in: set {env}=1 on the server": "Activation par l'opérateur : définir {env}=1 sur le serveur",
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Activation par l'opérateur : ajouter {id} à GEV_LIBRARY_ENABLE sur le serveur",
"Turned off by this instance's operator": "Désactivé par l'opérateur de cette instance",
"Needs a provider key on the server": "Nécessite une clé du fournisseur sur le serveur",
"Needs this app's server · not available in a static build": "Nécessite le serveur de cette app · indisponible dans une version statique",
"Checking what this instance serves…": "Vérification de ce que propose cette instance…",
"Regional": "Régional",
"Orthophoto": "Orthophoto",
"Satellite mosaic": "Mosaïque satellite",
"Oblique": "Oblique",
"Historic imagery": "Imagerie historique",
"True orthophoto": "Orthophoto vraie",
"Type": "Type",
"Resolution": "Résolution",
"Flown": "Vol",
"Updates": "Mise à jour",
"Ortho quality": "Qualité ortho",
"DATA LIBRARY": "BIBLIOTHÈQUE DE DONNÉES",
"Close data library": "Fermer la bibliothèque de données",
"Data Library at a glance": "La bibliothèque de données en un coup d'œil",
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Sources en direct au-delà des couches principales. Chacune est récupérée auprès de son fournisseur tant qu'elle est active ; rien n'est stocké dans cette app. Ajoutez-en une et elle apparaît dans Couches de données, sous Bibliothèque.",
"Name, provider or country": "Nom, fournisseur ou pays",
"Theme": "Thème",
"All themes": "Tous les thèmes",
"Only what this instance serves": "Seulement ce que propose cette instance",
"OPT-IN": "SUR ACTIVATION",
"KEY": "CLÉ",
"KEYLESS": "SANS CLÉ",
"ZOOM": "ZOOMER",
"Zoom to {name}": "Zoomer sur {name}",
"WORKING…": "EN COURS…",
"ADD": "AJOUTER",
"Remove {name} from Data Layers": "Retirer {name} des Couches de données",
"Add {name} to Data Layers": "Ajouter {name} aux Couches de données",
"CONFIRM · REMOVE ALL {count}": {
"one": "CONFIRMER · RETIRER {count}",
"many": "CONFIRMER · RETIRER LES {count}",
"other": "CONFIRMER · RETIRER LES {count}"
},
"REMOVE ALL": "TOUT RETIRER",
"Confirm: remove every added entry from Data Layers": "Confirmer : retirer des Couches de données toutes les entrées ajoutées",
"Remove every added entry from Data Layers": "Retirer des Couches de données toutes les entrées ajoutées",
"Showing {shown} of {total}": "{shown} sur {total} affichés",
"BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES"
} }
+123 -38
View File
@@ -1103,11 +1103,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato", "TomTom roads first; OpenStreetMap fills the rest, simulated": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato (richiede una chiave TomTom)", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato (richiede una chiave TomTom)",
"Hybrid": "Ibrido", "Hybrid": "Ibrido",
"Lines": "Linee",
"Dots": "Punti",
"Road lines colored by live flow: green to red": "Strade colorate in base al traffico in tempo reale: dal verde al rosso",
"Moving dots colored by live flow": "Punti in movimento colorati in base al traffico in tempo reale",
"Moving dots": "Punti in movimento",
"TomTom key unavailable": "Chiave TomTom non disponibile", "TomTom key unavailable": "Chiave TomTom non disponibile",
"TomTom daily budget reached": "Budget giornaliero TomTom esaurito", "TomTom daily budget reached": "Budget giornaliero TomTom esaurito",
"Monthly traffic allowance used": "Quota mensile del traffico esaurita", "Monthly traffic allowance used": "Quota mensile del traffico esaurita",
@@ -2810,8 +2805,6 @@
"AVAILABLE MISSIONS": "MISSIONI DISPONIBILI", "AVAILABLE MISSIONS": "MISSIONI DISPONIBILI",
"SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE", "SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE",
"LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI", "LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missioni spaziali non disponibili: impossibile caricare l’indice delle missioni. Riprova più tardi.",
"30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA",
"Internet radio and local SDR": "Radio internet e SDR locale", "Internet radio and local SDR": "Radio internet e SDR locale",
"STATION TAG": "TAG STAZIONE", "STATION TAG": "TAG STAZIONE",
@@ -2990,18 +2983,18 @@
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa.", "Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa.",
"Malformed cyclone snapshot": "Snapshot dei cicloni non valido", "Malformed cyclone snapshot": "Snapshot dei cicloni non valido",
"Cyclone request timed out": "La richiesta dei cicloni è andata in timeout", "Cyclone request timed out": "La richiesta dei cicloni è andata in timeout",
"{lower} to {upper}": "Da {lower} a {upper}", "{lower} to {upper}": "da {lower} a {upper}",
"Up to {upper}": "Fino a {upper}", "Up to {upper}": "Fino a {upper}",
"From {lower}": "Da {lower}", "From {lower}": "Da {lower}",
"Informational only · not for flight planning": "Solo a scopo informativo · non per la pianificazione del volo", "Informational only · not for flight planning": "Solo a scopo informativo · non per la pianificazione del volo",
"Unnamed zone": "Senza nome", "Unnamed zone": "Zona senza nome",
"Restricted airspace source unavailable": "Fonte dello spazio aereo regolamentato non disponibile", "Restricted airspace source unavailable": "Fonte dello spazio aereo regolamentato non disponibile",
"Prohibited (P)": "Zona vietata (P)", "Prohibited (P)": "Zona proibita (P)",
"Restricted (R)": "Zona regolamentata (R)", "Restricted (R)": "Zona regolamentata (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo zone vietate e regolamentate della FAA (spazio aereo a uso speciale), non la UAS Facility Map né le restrizioni di volo UAS. Solo a scopo informativo — non sostituisce gli strumenti ufficiali di pianificazione del volo UAS (B4UFLY / LAANC).", "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo zone proibite e regolamentate della FAA (spazio aereo a uso speciale), non la UAS Facility Map né le restrizioni di volo UAS. Solo a scopo informativo — non sostituisce gli strumenti ufficiali di pianificazione del volo UAS (B4UFLY / LAANC).",
"Prohibited": "Zona vietata", "Prohibited": "Zona proibita",
"Restricted": "Zona regolamentata", "Restricted": "Zona regolamentata",
"Prohibited & Restricted Airspace": "Spazio aereo vietato e regolamentato", "Prohibited & Restricted Airspace": "Zone proibite e regolamentate",
"scene|PLAY": "AVVIA", "scene|PLAY": "AVVIA",
"AUTHORING": "CREAZIONE", "AUTHORING": "CREAZIONE",
"Create and edit scenes": "Crea e modifica le scene", "Create and edit scenes": "Crea e modifica le scene",
@@ -3032,7 +3025,7 @@
"fire confidence filter|All": "Tutti", "fire confidence filter|All": "Tutti",
"fire confidence filter|Nominal+": "Nominale+", "fire confidence filter|Nominal+": "Nominale+",
"fire confidence filter|High": "Alta", "fire confidence filter|High": "Alta",
"Every detection — many of the strongest fires are rated low confidence": "Tutti i rilevamenti: molti degli incendi più intensi hanno confidenza bassa", "Every detection — many of the strongest fires are rated low confidence": "Tutti i rilevamenti — molti degli incendi più intensi hanno confidenza bassa",
"Nominal and high confidence detections": "Rilevamenti con confidenza nominale e alta", "Nominal and high confidence detections": "Rilevamenti con confidenza nominale e alta",
"High confidence detections only": "Solo rilevamenti con confidenza alta", "High confidence detections only": "Solo rilevamenti con confidenza alta",
"Fire intensity (FRP)": "Intensità (FRP)", "Fire intensity (FRP)": "Intensità (FRP)",
@@ -3041,7 +3034,7 @@
"Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt", "Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt",
"fire intensity filter|All": "Tutti", "fire intensity filter|All": "Tutti",
"Detection confidence": "Confidenza del rilevamento", "Detection confidence": "Confidenza del rilevamento",
"{shown} of {total} shown": "Mostrati: {shown} su {total}", "{shown} of {total} shown": "{shown} di {total} mostrati",
"Name of {name}": "Nome di {name}", "Name of {name}": "Nome di {name}",
"Change the colour of {name}": "Cambia il colore di {name}", "Change the colour of {name}": "Cambia il colore di {name}",
"Colour of {name}": "Colore di {name}", "Colour of {name}": "Colore di {name}",
@@ -3059,10 +3052,10 @@
"natural region": "regione naturale", "natural region": "regione naturale",
"Which {place}?": "Quale {place}?", "Which {place}?": "Quale {place}?",
"Which place?": "Quale luogo?", "Which place?": "Quale luogo?",
"Not drawn: {parts}": "Contorno non disegnato: {parts}", "Not drawn: {parts}": "Non disegnati: {parts}",
"approximate": "approssimata", "approximate": "approssimativo",
"{pct}% cloud": "{pct}% di nuvole", "{pct}% cloud": "{pct}% di nuvole",
"Over {area}": "Area: {area}", "Over {area}": "Zona: {area}",
"{count} of {total} acquisitions match": { "{count} of {total} acquisitions match": {
"many": "Acquisizioni corrispondenti: {count} su {total}", "many": "Acquisizioni corrispondenti: {count} su {total}",
"one": "Acquisizioni corrispondenti: {count} su {total}", "one": "Acquisizioni corrispondenti: {count} su {total}",
@@ -3081,8 +3074,8 @@
"In {area}": "Area: {area}", "In {area}": "Area: {area}",
"Area around {place}": "Area intorno a {place}", "Area around {place}": "Area intorno a {place}",
"Area around landmark": "Area intorno a un punto di interesse", "Area around landmark": "Area intorno a un punto di interesse",
"Outline {place}": "Traccia il contorno di {place}", "Outline {place}": "Traccia contorno di {place}",
"Outline area": "Traccia il contorno dell'area", "Outline area": "Traccia contorno area",
"Find {place}": "Trova {place}", "Find {place}": "Trova {place}",
"Find area": "Trova area", "Find area": "Trova area",
"Find recent imagery": "Trova immagini recenti", "Find recent imagery": "Trova immagini recenti",
@@ -3090,16 +3083,16 @@
"Find places": "Trova luoghi", "Find places": "Trova luoghi",
"Drawing outline": "Disegno del contorno", "Drawing outline": "Disegno del contorno",
"Searching imagery catalog": "Ricerca nel catalogo immagini", "Searching imagery catalog": "Ricerca nel catalogo immagini",
"Loading imagery": "Caricamento delle immagini", "Loading imagery": "Caricamento immagini",
"Searching OpenStreetMap": "Ricerca in OpenStreetMap", "Searching OpenStreetMap": "Ricerca in OpenStreetMap",
"voice area|Area": "Area", "voice area|Area": "Area",
"buffer sized to the place": "zona tampone delle dimensioni del luogo", "buffer sized to the place": "zona tampone delle dimensioni del luogo",
"enclosing mapped area": "area mappata che contiene il punto di interesse", "enclosing mapped area": "area mappata circostante",
"the map was cleared while looking it up": "la mappa è stata ripulita durante la ricerca", "the map was cleared while looking it up": "la mappa è stata cancellata durante la ricerca",
"OpenStreetMap coverage varies by place": "la copertura di OpenStreetMap varia da luogo a luogo", "OpenStreetMap coverage varies by place": "La copertura di OpenStreetMap varia da luogo a luogo",
"the catalog covers the last 30 days": "il catalogo copre gli ultimi 30 giorni", "the catalog covers the last 30 days": "il catalogo copre gli ultimi 30 giorni",
"there are more": "ce ne sono altri", "there are more": "ce ne sono altri",
"{radius} m around the landmark": "{radius} m intorno al punto di interesse", "{radius} m around the landmark": "{radius} m attorno al luogo",
"{count} parts": { "{count} parts": {
"many": "Parti: {count}", "many": "Parti: {count}",
"one": "Parti: {count}", "one": "Parti: {count}",
@@ -3115,8 +3108,8 @@
"one": "Duplicati uniti: {count}", "one": "Duplicati uniti: {count}",
"other": "Duplicati uniti: {count}" "other": "Duplicati uniti: {count}"
}, },
"listed the first {limit}": "elenco limitato ai primi risultati: {limit}", "listed the first {limit}": "elencati i primi {limit}",
"counted over the bounding box of {area}, not its outline": "conteggio nel riquadro di delimitazione di {area}, non entro il suo contorno", "counted over the bounding box of {area}, not its outline": "contato sul riquadro di delimitazione ({area}), non sul contorno",
"Search cancelled": "Ricerca annullata", "Search cancelled": "Ricerca annullata",
"Sea Marks": "Segnalamenti marittimi", "Sea Marks": "Segnalamenti marittimi",
"Ski Pistes": "Piste da sci", "Ski Pistes": "Piste da sci",
@@ -3249,15 +3242,107 @@
"iNaturalist": "iNaturalist", "iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "Contributori di iNaturalist · licenze per osservazione", "iNaturalist contributors · licences per record": "Contributori di iNaturalist · licenze per osservazione",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributori di iNaturalist · Acartia {count} (Mare dei Salish) · licenze per osservazione", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributori di iNaturalist · Acartia {count} (Mare dei Salish) · licenze per osservazione",
"{days}d": "{days}g", "tiles unavailable": "tile non disponibili",
"All markers": "Tutti gli indicatori", "zoom in to load this layer": "avvicinati per caricare questo livello",
"Show markers of every age": "Mostra gli indicatori di qualsiasi età", "Zoom to layer": "Zoom sul livello",
"Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti", "Zoom to layer · fly to where this layer has data": "Zoom sul livello · vola dove questo livello ha dati",
"event age filter|All": "Tutti", "Remove from Data Layers": "Rimuovi da Livelli dati",
"Event age": "Età degli eventi", "Remove from Data Layers · add it again from the library": "Rimuovi da Livelli dati · aggiungilo di nuovo dalla libreria",
"Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti", "Coverage outline": "Contorno di copertura",
"Close camera preview": "Chiudi anteprima della telecamera", "DRAPED": "DRAPPEGGIATO",
"Refresh map still": "Aggiorna immagine fissa sulla mappa", "Below 250 m": "Sotto i 250 m",
"The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.", "250–500 m": "250–500 m",
"Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP." "500–1,000 m": "500–1.000 m",
"1,000–2,000 m": "1.000–2.000 m",
"2,000–3,000 m": "2.000–3.000 m",
"3,000 m and above": "3.000 m e oltre",
"Exit cockpit to fly to a layer": "Esci dalla cabina di pilotaggio per volare a un livello",
"Library": "Libreria",
"Environment": "Ambiente",
"Imagery": "Immagini",
"Demographics": "Demografia",
"DATASET": "DATASET",
"TILES": "TILE",
"PACK": "PACCHETTO",
"INCUBATING": "IN INCUBAZIONE",
"COMMUNITY": "COMUNITÀ",
"VERIFIED": "VERIFICATO",
"ARCHIVED": "ARCHIVIATO",
"Datasets": "Dataset",
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
"{count} datasets": {
"one": "{count} dataset",
"many": "{count} di dataset",
"other": "{count} dataset"
},
"{count} tile layers": {
"one": "{count} livello di tile",
"many": "{count} di livelli di tile",
"other": "{count} livelli di tile"
},
"{count} packs": {
"one": "{count} pacchetto",
"many": "{count} di pacchetti",
"other": "{count} pacchetti"
},
"Themes": "Temi",
"Added": "Aggiunti",
"{count} on the map": {
"one": "{count} sulla mappa",
"many": "{count} sulla mappa",
"other": "{count} sulla mappa"
},
"none yet": "ancora nessuno",
"Map features": "Elementi della mappa",
"from {count} datasets": {
"one": "da {count} dataset",
"many": "da {count} di dataset",
"other": "da {count} dataset"
},
"switch on a dataset to load features": "attiva un dataset per caricare gli elementi",
"Switched on by this instance · part of the CCTV layer": "Attivato da questa istanza · parte del livello CCTV",
"Operator opt-in: set {env}=1 on the server": "Attivazione dell'operatore: imposta {env}=1 sul server",
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Attivazione dell'operatore: aggiungi {id} a GEV_LIBRARY_ENABLE sul server",
"Turned off by this instance's operator": "Disattivato dall'operatore di questa istanza",
"Needs a provider key on the server": "Richiede una chiave del fornitore sul server",
"Needs this app's server · not available in a static build": "Richiede il server di questa app · non disponibile in una build statica",
"Checking what this instance serves…": "Verifica di ciò che offre questa istanza…",
"Regional": "Regionale",
"Orthophoto": "Ortofoto",
"Satellite mosaic": "Mosaico satellitare",
"Oblique": "Obliqua",
"Historic imagery": "Immagini storiche",
"True orthophoto": "Ortofoto vera",
"Type": "Tipo",
"Resolution": "Risoluzione",
"Flown": "Volo",
"Updates": "Aggiornamenti",
"Ortho quality": "Qualità ortofoto",
"DATA LIBRARY": "LIBRERIA DATI",
"Close data library": "Chiudi la libreria dati",
"Data Library at a glance": "La libreria dati in sintesi",
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fonti in tempo reale oltre ai livelli principali. Ognuna viene caricata dal suo fornitore finché è attiva; questa app non memorizza nulla. Aggiungine una e comparirà in Livelli dati, sotto Libreria.",
"Name, provider or country": "Nome, fornitore o paese",
"Theme": "Tema",
"All themes": "Tutti i temi",
"Only what this instance serves": "Solo ciò che offre questa istanza",
"OPT-IN": "SU ATTIVAZIONE",
"KEY": "CHIAVE",
"KEYLESS": "SENZA CHIAVE",
"ZOOM": "ZOOM",
"Zoom to {name}": "Zoom su {name}",
"WORKING…": "IN CORSO…",
"ADD": "AGGIUNGI",
"Remove {name} from Data Layers": "Rimuovi {name} da Livelli dati",
"Add {name} to Data Layers": "Aggiungi {name} a Livelli dati",
"CONFIRM · REMOVE ALL {count}": {
"one": "CONFERMA · RIMUOVI {count}",
"many": "CONFERMA · RIMUOVI TUTTI I {count}",
"other": "CONFERMA · RIMUOVI TUTTI I {count}"
},
"REMOVE ALL": "RIMUOVI TUTTO",
"Confirm: remove every added entry from Data Layers": "Conferma: rimuovi da Livelli dati tutte le voci aggiunte",
"Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte",
"Showing {shown} of {total}": "{shown} di {total} mostrati",
"BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI"
} }
+125 -52
View File
@@ -1071,11 +1071,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション)", "TomTom roads first; OpenStreetMap fills the rest, simulated": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション)",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション、TomTomキーが必要)", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション、TomTomキーが必要)",
"Hybrid": "ハイブリッド", "Hybrid": "ハイブリッド",
"Lines": "ライン",
"Dots": "ドット",
"Road lines colored by live flow: green to red": "ライブの交通流で色分けした道路ライン(緑から赤)",
"Moving dots colored by live flow": "ライブの交通流で色分けした移動ドット",
"Moving dots": "移動ドット",
"TomTom key unavailable": "TomTomキーを利用できません", "TomTom key unavailable": "TomTomキーを利用できません",
"TomTom daily budget reached": "TomTomの1日の上限に達しました", "TomTom daily budget reached": "TomTomの1日の上限に達しました",
"Monthly traffic allowance used": "今月の交通情報の上限に達しました", "Monthly traffic allowance used": "今月の交通情報の上限に達しました",
@@ -2668,8 +2663,6 @@
"AVAILABLE MISSIONS": "利用可能なミッション", "AVAILABLE MISSIONS": "利用可能なミッション",
"SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示", "SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示",
"LOADING 30-DAY MISSION INDEX": "30日間のミッション一覧を読込中", "LOADING 30-DAY MISSION INDEX": "30日間のミッション一覧を読込中",
"Space Missions unavailable: the mission index could not load. Try again later.": "宇宙ミッションを利用できません:ミッション一覧を読み込めませんでした。後でもう一度お試しください。",
"30-DAY MISSION INDEX UNAVAILABLE": "30日間のミッション一覧を取得できません",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "Tabでプレビュー · Enter / Spaceで選択", "TAB PREVIEWS · ENTER / SPACE SELECTS": "Tabでプレビュー · Enter / Spaceで選択",
"Internet radio and local SDR": "インターネットラジオとローカルSDR", "Internet radio and local SDR": "インターネットラジオとローカルSDR",
"STATION TAG": "局タグ", "STATION TAG": "局タグ",
@@ -2850,18 +2843,18 @@
"route proxy error": "ルート検索プロキシでエラーが発生しました", "route proxy error": "ルート検索プロキシでエラーが発生しました",
"Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です", "Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です",
"Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました", "Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました",
"{lower} to {upper}": "{lower}–{upper}", "{lower} to {upper}": "{lower}~{upper}",
"Up to {upper}": "{upper}まで", "Up to {upper}": "{upper}まで",
"From {lower}": "{lower}から", "From {lower}": "{lower}から",
"Informational only · not for flight planning": "参考情報のみ · 飛行計画には使用しないでください", "Informational only · not for flight planning": "参考情報のみ · 飛行計画には使用しないでください",
"Unnamed zone": "名称不明の区域", "Unnamed zone": "名称なしのゾーン",
"Restricted airspace source unavailable": "制限空域のデータソースを利用できません", "Restricted airspace source unavailable": "制限空域のデータソースを利用できません",
"Prohibited (P)": "飛行禁止区域(P)", "Prohibited (P)": "飛行禁止区域 (P)",
"Restricted (R)": "飛行制限区域(R)", "Restricted (R)": "制限区域 (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "FAAの特別使用空域のうち、飛行禁止区域と飛行制限区域のみです。UAS Facility MapやUASの飛行制限は含みません。参考情報であり、公式のUAS飛行計画ツール(B4UFLY / LAANC)の代わりにはなりません。", "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "FAAの飛行禁止区域と制限区域(特別使用空域)のみで、UAS Facility MapやUAS飛行制限は含みません。参考情報のみ — 公式のUAS飛行計画ツール(B4UFLY / LAANC)の代わりにはなりません。",
"Prohibited": "飛行禁止区域", "Prohibited": "飛行禁止区域",
"Restricted": "飛行制限区域", "Restricted": "制限区域",
"Prohibited & Restricted Airspace": "飛行禁止・飛行制限区域", "Prohibited & Restricted Airspace": "飛行禁止区域と制限区域",
"scene|PLAY": "再生", "scene|PLAY": "再生",
"AUTHORING": "作成", "AUTHORING": "作成",
"Create and edit scenes": "シーンを作成・編集", "Create and edit scenes": "シーンを作成・編集",
@@ -2892,10 +2885,10 @@
"fire confidence filter|All": "すべて", "fire confidence filter|All": "すべて",
"fire confidence filter|Nominal+": "中以上", "fire confidence filter|Nominal+": "中以上",
"fire confidence filter|High": "高", "fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "すべての検知。最も強い火災の多くは信頼度「低」と判定されています", "Every detection — many of the strongest fires are rated low confidence": "すべての検知 — 最も強い火災の多くは信頼度が低と判定されています",
"Nominal and high confidence detections": "信頼度「中」以上の検知", "Nominal and high confidence detections": "信頼度が中・高の検知",
"High confidence detections only": "信頼度「高」の検知のみ", "High confidence detections only": "信頼度が高の検知のみ",
"Fire intensity (FRP)": "火災強度(FRP)", "Fire intensity (FRP)": "強度(FRP)",
"≥ {value} MW": "≥ {value} MW", "≥ {value} MW": "≥ {value} MW",
"Confidence": "信頼度", "Confidence": "信頼度",
"Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)", "Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)",
@@ -2913,16 +2906,16 @@
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル", "Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
"Voice place search (needs an Overpass server)": "音声による場所検索(Overpassサーバーが必要)", "Voice place search (needs an Overpass server)": "音声による場所検索(Overpassサーバーが必要)",
"OSM Places": "OSMの場所", "OSM Places": "OSMの場所",
"state or province": "州・県", "state or province": "州・省",
"county or district": "郡・地区", "county or district": "郡・地区",
"neighbourhood": "近隣地区", "neighbourhood": "近隣地区",
"natural region": "自然地域", "natural region": "自然地域",
"Which {place}?": "どの「{place}」ですか?", "Which {place}?": "どの{place}ですか?",
"Which place?": "どの場所ですか?", "Which place?": "どの場所ですか?",
"Not drawn: {parts}": "未描画:{parts}", "Not drawn: {parts}": "未描画:{parts}",
"approximate": "近似", "approximate": "概算",
"{pct}% cloud": "雲量 {pct}%", "{pct}% cloud": "雲量{pct}%",
"Over {area}": "範囲:{area}", "Over {area}": "{area}上空",
"{count} of {total} acquisitions match": { "{count} of {total} acquisitions match": {
"other": "一致した画像:{count}/{total}件" "other": "一致した画像:{count}/{total}件"
}, },
@@ -2931,45 +2924,45 @@
}, },
"No matching imagery": "一致する画像はありません", "No matching imagery": "一致する画像はありません",
"Unknown number of {kind}": "{kind}:件数不明", "Unknown number of {kind}": "{kind}:件数不明",
"About {count} {kind}": "{kind}:約{count}か所", "About {count} {kind}": "{kind}:約{count}件",
"At least {count} {kind}": "{kind}:{count}か所以上", "At least {count} {kind}": "{kind}:{count}件以上",
"{count} {kind}": "{kind}:{count}か所", "{count} {kind}": "{kind}:{count}件",
"In {area}": "{area}内", "In {area}": "{area}内",
"Area around {place}": "{place}周辺のエリア", "Area around {place}": "{place}周辺のエリア",
"Area around landmark": "ランドマーク周辺のエリア", "Area around landmark": "ランドマーク周辺のエリア",
"Outline {place}": "{place}の輪郭を描画", "Outline {place}": "{place}の輪郭を描く",
"Outline area": "エリアの輪郭を描画", "Outline area": "エリアの輪郭を描く",
"Find {place}": "{place}を検索", "Find {place}": "{place}を検索",
"Find area": "エリアを検索", "Find area": "エリアを検索",
"Find recent imagery": "最近の衛星画像を検索", "Find recent imagery": "最近の衛星画像を検索",
"Find {what}": "{what}を検索", "Find {what}": "{what}を検索",
"Find places": "場所を検索", "Find places": "場所を検索",
"Drawing outline": "輪郭を地図に描画中", "Drawing outline": "輪郭を作図中",
"Searching imagery catalog": "画像カタログを検索中", "Searching imagery catalog": "画像カタログを検索中",
"Loading imagery": "画像を読み込み中", "Loading imagery": "画像を読み込み中",
"Searching OpenStreetMap": "OpenStreetMapを検索中", "Searching OpenStreetMap": "OpenStreetMapを検索中",
"voice area|Area": "エリア", "voice area|Area": "エリア",
"buffer sized to the place": "場所の大きさに合わせたバッファー", "buffer sized to the place": "場所の大きさに合わせたバッファ",
"enclosing mapped area": "ランドマークを含むマップ済みのエリア", "enclosing mapped area": "周囲の地図上のエリア",
"the map was cleared while looking it up": "検索中に地図が消去されました", "the map was cleared while looking it up": "検索中に地図がクリアされました",
"OpenStreetMap coverage varies by place": "OpenStreetMapの網羅度は場所によって異なります", "OpenStreetMap coverage varies by place": "OpenStreetMap の網羅度は場所によって異なります",
"the catalog covers the last 30 days": "カタログの対象は過去30日間です", "the catalog covers the last 30 days": "カタログは過去 30 日分です",
"there are more": "ほかにもあります", "there are more": "ほかにもあります",
"{radius} m around the landmark": "ランドマークから半径{radius} m", "{radius} m around the landmark": "ランドマークの周囲 {radius} m",
"{count} parts": { "{count} parts": {
"other": "パーツ数:{count}" "other": "パーツ数: {count}"
}, },
"{drawn} of {total} parts drawn": { "{drawn} of {total} parts drawn": {
"other": "描画したパーツ:{drawn}/{total}" "other": "描画したパーツ: {drawn}/{total}"
}, },
"{count} duplicate mappings merged": { "{count} duplicate mappings merged": {
"other": "重複登録{count}件を統合" "other": "統合した重複: {count}"
}, },
"listed the first {limit}": "最初の{limit}件を表示", "listed the first {limit}": "最初の {limit} 件を表示",
"counted over the bounding box of {area}, not its outline": "{area}の輪郭ではなく外接矩形の範囲で数えています", "counted over the bounding box of {area}, not its outline": "外接矩形 ({area}) で数えた結果で、輪郭ではありません",
"Search cancelled": "検索をキャンセルしました", "Search cancelled": "検索をキャンセルしました",
"Sea Marks": "航路標識", "Sea Marks": "航路標識",
"Ski Pistes": "スキーコース", "Ski Pistes": "スキーゲレンデ",
"Fly to Tokyo": "東京へ移動して", "Fly to Tokyo": "東京へ移動して",
"Show live flights": "ライブフライトを表示して", "Show live flights": "ライブフライトを表示して",
"Reset to globe view": "地球全体ビューに戻して", "Reset to globe view": "地球全体ビューに戻して",
@@ -3099,15 +3092,95 @@
"iNaturalist": "iNaturalist", "iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "iNaturalist の投稿者 · ライセンスは記録ごと", "iNaturalist contributors · licences per record": "iNaturalist の投稿者 · ライセンスは記録ごと",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist の投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist の投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと",
"{days}d": "{days}日", "tiles unavailable": "タイルを利用できません",
"All markers": "すべてのマーカー", "zoom in to load this layer": "ズームインしてこのレイヤーを読み込む",
"Show markers of every age": "日付にかかわらずすべてのマーカーを表示", "Zoom to layer": "レイヤーにズーム",
"Maximum event age: {days}d; undated markers are kept": "イベントの最大経過日数:{days}日。日付のないマーカーは保持", "Zoom to layer · fly to where this layer has data": "レイヤーにズーム · このレイヤーのデータがある場所へ移動",
"event age filter|All": "すべて", "Remove from Data Layers": "データレイヤーから削除",
"Event age": "経過日数", "Remove from Data Layers · add it again from the library": "データレイヤーから削除 · ライブラリから再度追加できます",
"Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持", "Coverage outline": "カバー範囲の輪郭",
"Close camera preview": "カメラのプレビューを閉じる", "DRAPED": "地形に投影",
"Refresh map still": "地図上の静止画を更新", "Below 250 m": "250 m 未満",
"The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。", "250–500 m": "250–500 m",
"Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。" "500–1,000 m": "500–1,000 m",
"1,000–2,000 m": "1,000–2,000 m",
"2,000–3,000 m": "2,000–3,000 m",
"3,000 m and above": "3,000 m 以上",
"Exit cockpit to fly to a layer": "レイヤーへ移動するにはコックピットを終了してください",
"Library": "ライブラリ",
"Environment": "環境",
"Imagery": "画像",
"Demographics": "人口統計",
"DATASET": "データセット",
"TILES": "タイル",
"PACK": "パック",
"INCUBATING": "試験中",
"COMMUNITY": "コミュニティ",
"VERIFIED": "検証済み",
"ARCHIVED": "アーカイブ済み",
"Datasets": "データセット",
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
"{count} datasets": {
"other": "{count} 件のデータセット"
},
"{count} tile layers": {
"other": "{count} 件のタイルレイヤー"
},
"{count} packs": {
"other": "{count} 件のパック"
},
"Themes": "テーマ",
"Added": "追加済み",
"{count} on the map": {
"other": "地図上に {count} 件"
},
"none yet": "まだありません",
"Map features": "地図上の地物",
"from {count} datasets": {
"other": "{count} 件のデータセットから"
},
"switch on a dataset to load features": "データセットをオンにすると地物を読み込みます",
"Switched on by this instance · part of the CCTV layer": "このインスタンスで有効 · CCTV レイヤーの一部",
"Operator opt-in: set {env}=1 on the server": "運用者による有効化:サーバーで {env}=1 を設定",
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "運用者による有効化:サーバーの GEV_LIBRARY_ENABLE に {id} を追加",
"Turned off by this instance's operator": "このインスタンスの運用者により無効化されています",
"Needs a provider key on the server": "サーバーにプロバイダーのキーが必要です",
"Needs this app's server · not available in a static build": "このアプリのサーバーが必要 · 静的ビルドでは利用できません",
"Checking what this instance serves…": "このインスタンスの提供内容を確認中…",
"Regional": "地域",
"Orthophoto": "オルソ画像",
"Satellite mosaic": "衛星モザイク",
"Oblique": "斜め写真",
"Historic imagery": "過去の画像",
"True orthophoto": "トゥルーオルソ画像",
"Type": "種類",
"Resolution": "解像度",
"Flown": "撮影",
"Updates": "更新",
"Ortho quality": "オルソ品質",
"DATA LIBRARY": "データライブラリ",
"Close data library": "データライブラリを閉じる",
"Data Library at a glance": "データライブラリの概要",
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "コアレイヤー以外のライブソースです。オンの間は各プロバイダーから取得され、このアプリには何も保存されません。追加すると、データレイヤーの「ライブラリ」に表示されます。",
"Name, provider or country": "名前、プロバイダー、国",
"Theme": "テーマ",
"All themes": "すべてのテーマ",
"Only what this instance serves": "このインスタンスが提供するもののみ",
"OPT-IN": "要有効化",
"KEY": "キー必須",
"KEYLESS": "キー不要",
"ZOOM": "ズーム",
"Zoom to {name}": "{name} にズーム",
"WORKING…": "処理中…",
"ADD": "追加",
"Remove {name} from Data Layers": "{name} をデータレイヤーから削除",
"Add {name} to Data Layers": "{name} をデータレイヤーに追加",
"CONFIRM · REMOVE ALL {count}": {
"other": "確認 · {count} 件をすべて削除"
},
"REMOVE ALL": "すべて削除",
"Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除",
"Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除",
"Showing {shown} of {total}": "{total} 件中 {shown} 件を表示",
"BROWSE DATA LIBRARY": "データライブラリを見る"
} }
+125 -40
View File
@@ -1101,11 +1101,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado", "TomTom roads first; OpenStreetMap fills the rest, simulated": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado (precisa de uma chave do TomTom)", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado (precisa de uma chave do TomTom)",
"Hybrid": "Híbrido", "Hybrid": "Híbrido",
"Lines": "Linhas",
"Dots": "Pontos",
"Road lines colored by live flow: green to red": "Vias coloridas pelo fluxo ao vivo: do verde ao vermelho",
"Moving dots colored by live flow": "Pontos em movimento coloridos pelo fluxo ao vivo",
"Moving dots": "Pontos em movimento",
"TomTom key unavailable": "Chave do TomTom indisponível", "TomTom key unavailable": "Chave do TomTom indisponível",
"TomTom daily budget reached": "Limite diário do TomTom atingido", "TomTom daily budget reached": "Limite diário do TomTom atingido",
"Monthly traffic allowance used": "Cota mensal de trânsito esgotada", "Monthly traffic allowance used": "Cota mensal de trânsito esgotada",
@@ -2812,8 +2807,6 @@
"AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS", "AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS",
"SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR", "SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR",
"LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS", "LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missões espaciais indisponíveis: não foi possível carregar o índice de missões. Tente novamente mais tarde.",
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISSÕES DE 30 DIAS INDISPONÍVEL",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB MOSTRA PRÉVIA · ENTER / SPACE SELECIONA", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB MOSTRA PRÉVIA · ENTER / SPACE SELECIONA",
"Internet radio and local SDR": "Rádio pela internet e SDR local", "Internet radio and local SDR": "Rádio pela internet e SDR local",
"STATION TAG": "TAG DA ESTAÇÃO", "STATION TAG": "TAG DA ESTAÇÃO",
@@ -2994,14 +2987,14 @@
"Up to {upper}": "Até {upper}", "Up to {upper}": "Até {upper}",
"From {lower}": "A partir de {lower}", "From {lower}": "A partir de {lower}",
"Informational only · not for flight planning": "Apenas informativo · não usar para planejamento de voo", "Informational only · not for flight planning": "Apenas informativo · não usar para planejamento de voo",
"Unnamed zone": "Sem nome", "Unnamed zone": "Zona sem nome",
"Restricted airspace source unavailable": "Fonte de espaço aéreo restrito indisponível", "Restricted airspace source unavailable": "Fonte de espaço aéreo restrito indisponível",
"Prohibited (P)": "Área proibida (P)", "Prohibited (P)": "Área proibida (P)",
"Restricted (R)": "Área restrita (R)", "Restricted (R)": "Área restrita (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Somente o espaço aéreo de uso especial proibido e restrito da FAA; não inclui o UAS Facility Map nem as restrições de voo de UAS. Apenas informativo — não substitui as ferramentas oficiais de planejamento de voo de UAS (B4UFLY / LAANC).", "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Apenas áreas proibidas e restritas da FAA (espaço aéreo de uso especial), sem o UAS Facility Map ou as restrições de voo de UAS. Apenas informativo — não substitui as ferramentas oficiais de planejamento de voo de UAS (B4UFLY / LAANC).",
"Prohibited": "Área proibida", "Prohibited": "Área proibida",
"Restricted": "Área restrita", "Restricted": "Área restrita",
"Prohibited & Restricted Airspace": "Espaço aéreo proibido e restrito", "Prohibited & Restricted Airspace": "Áreas proibidas e restritas",
"scene|PLAY": "INICIAR", "scene|PLAY": "INICIAR",
"AUTHORING": "CRIAÇÃO", "AUTHORING": "CRIAÇÃO",
"Create and edit scenes": "Criar e editar cenas", "Create and edit scenes": "Criar e editar cenas",
@@ -3041,7 +3034,7 @@
"Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts", "Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts",
"fire intensity filter|All": "Todas", "fire intensity filter|All": "Todas",
"Detection confidence": "Confiança da detecção", "Detection confidence": "Confiança da detecção",
"{shown} of {total} shown": "Detecções exibidas: {shown} de {total}", "{shown} of {total} shown": "{shown} de {total} exibidas",
"Name of {name}": "Nome de {name}", "Name of {name}": "Nome de {name}",
"Change the colour of {name}": "Mudar a cor de {name}", "Change the colour of {name}": "Mudar a cor de {name}",
"Colour of {name}": "Cor de {name}", "Colour of {name}": "Cor de {name}",
@@ -3060,18 +3053,18 @@
"Which {place}?": "Qual {place}?", "Which {place}?": "Qual {place}?",
"Which place?": "Qual lugar?", "Which place?": "Qual lugar?",
"Not drawn: {parts}": "Não desenhado: {parts}", "Not drawn: {parts}": "Não desenhado: {parts}",
"approximate": "aproximada", "approximate": "aproximado",
"{pct}% cloud": "{pct}% de nuvens", "{pct}% cloud": "{pct}% de nuvens",
"Over {area}": "Área: {area}", "Over {area}": "Área: {area}",
"{count} of {total} acquisitions match": { "{count} of {total} acquisitions match": {
"one": "Imagens correspondentes: {count} de {total}", "many": "Aquisições correspondentes: {count} de {total}",
"many": "Imagens correspondentes: {count} de {total}", "one": "Aquisições correspondentes: {count} de {total}",
"other": "Imagens correspondentes: {count} de {total}" "other": "Aquisições correspondentes: {count} de {total}"
}, },
"{n} acquisitions searched": { "{n} acquisitions searched": {
"one": "Imagens pesquisadas: {n}", "many": "Aquisições pesquisadas: {n}",
"many": "Imagens pesquisadas: {n}", "one": "Aquisições pesquisadas: {n}",
"other": "Imagens pesquisadas: {n}" "other": "Aquisições pesquisadas: {n}"
}, },
"No matching imagery": "Nenhuma imagem correspondente", "No matching imagery": "Nenhuma imagem correspondente",
"Unknown number of {kind}": "{kind}: número desconhecido", "Unknown number of {kind}": "{kind}: número desconhecido",
@@ -3083,23 +3076,23 @@
"Area around landmark": "Área ao redor de um ponto de referência", "Area around landmark": "Área ao redor de um ponto de referência",
"Outline {place}": "Traçar contorno de {place}", "Outline {place}": "Traçar contorno de {place}",
"Outline area": "Traçar contorno da área", "Outline area": "Traçar contorno da área",
"Find {place}": "Buscar {place}", "Find {place}": "Encontrar {place}",
"Find area": "Buscar área", "Find area": "Encontrar área",
"Find recent imagery": "Buscar imagens recentes", "Find recent imagery": "Encontrar imagens recentes",
"Find {what}": "Buscar {what}", "Find {what}": "Encontrar {what}",
"Find places": "Buscar lugares", "Find places": "Encontrar lugares",
"Drawing outline": "Desenhando contorno", "Drawing outline": "Desenhando contorno",
"Searching imagery catalog": "Buscando no catálogo de imagens", "Searching imagery catalog": "Buscando no catálogo de imagens",
"Loading imagery": "Carregando imagens", "Loading imagery": "Carregando imagens",
"Searching OpenStreetMap": "Buscando no OpenStreetMap", "Searching OpenStreetMap": "Buscando no OpenStreetMap",
"voice area|Area": "Área", "voice area|Area": "Área",
"buffer sized to the place": "círculo do tamanho aproximado do lugar", "buffer sized to the place": "zona de margem do tamanho do local",
"enclosing mapped area": "área mapeada que contém o ponto de referência", "enclosing mapped area": "área mapeada envolvente",
"the map was cleared while looking it up": "o mapa foi limpo durante a busca", "the map was cleared while looking it up": "o mapa foi limpo durante a busca",
"OpenStreetMap coverage varies by place": "A cobertura do OpenStreetMap varia de lugar para lugar", "OpenStreetMap coverage varies by place": "A cobertura do OpenStreetMap varia de lugar para lugar",
"the catalog covers the last 30 days": "o catálogo cobre os últimos 30 dias", "the catalog covers the last 30 days": "o catálogo cobre os últimos 30 dias",
"there are more": "há mais", "there are more": "há mais",
"{radius} m around the landmark": "{radius} m ao redor do ponto de referência", "{radius} m around the landmark": "{radius} m ao redor do local",
"{count} parts": { "{count} parts": {
"many": "Partes: {count}", "many": "Partes: {count}",
"one": "Partes: {count}", "one": "Partes: {count}",
@@ -3115,10 +3108,10 @@
"one": "Duplicatas mescladas: {count}", "one": "Duplicatas mescladas: {count}",
"other": "Duplicatas mescladas: {count}" "other": "Duplicatas mescladas: {count}"
}, },
"listed the first {limit}": "lista limitada a {limit}", "listed the first {limit}": "listados os primeiros {limit}",
"counted over the bounding box of {area}, not its outline": "contagem na caixa delimitadora de {area}, não no contorno", "counted over the bounding box of {area}, not its outline": "contado na caixa delimitadora ({area}), não no contorno",
"Search cancelled": "Busca cancelada", "Search cancelled": "Busca cancelada",
"Sea Marks": "Sinalização náutica", "Sea Marks": "Sinais marítimos",
"Ski Pistes": "Pistas de esqui", "Ski Pistes": "Pistas de esqui",
"Fly to Tokyo": "Voe para Tóquio", "Fly to Tokyo": "Voe para Tóquio",
"Show live flights": "Mostre os voos ao vivo", "Show live flights": "Mostre os voos ao vivo",
@@ -3249,15 +3242,107 @@
"iNaturalist": "iNaturalist", "iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "Colaboradores do iNaturalist · licenças por registro", "iNaturalist contributors · licences per record": "Colaboradores do iNaturalist · licenças por registro",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores do iNaturalist · Acartia {count} (mar de Salish) · licenças por registro", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores do iNaturalist · Acartia {count} (mar de Salish) · licenças por registro",
"{days}d": "{days}d", "tiles unavailable": "blocos indisponíveis",
"All markers": "Todos os marcadores", "zoom in to load this layer": "aproxime para carregar esta camada",
"Show markers of every age": "Mostrar marcadores de qualquer idade", "Zoom to layer": "Ir para a camada",
"Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} dias; marcadores sem data mantidos", "Zoom to layer · fly to where this layer has data": "Ir para a camada · voe até onde esta camada tem dados",
"event age filter|All": "Todos", "Remove from Data Layers": "Remover de Camadas de dados",
"Event age": "Idade do evento", "Remove from Data Layers · add it again from the library": "Remover de Camadas de dados · adicione de novo pela biblioteca",
"Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos", "Coverage outline": "Contorno de cobertura",
"Close camera preview": "Fechar prévia da câmera", "DRAPED": "SOBREPOSTO",
"Refresh map still": "Atualizar imagem fixa no mapa", "Below 250 m": "Abaixo de 250 m",
"The map still uses a separate request and may show a different moment.": "A imagem no mapa usa uma solicitação separada e pode mostrar outro momento.", "250–500 m": "250–500 m",
"Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP." "500–1,000 m": "500–1.000 m",
"1,000–2,000 m": "1.000–2.000 m",
"2,000–3,000 m": "2.000–3.000 m",
"3,000 m and above": "3.000 m ou mais",
"Exit cockpit to fly to a layer": "Saia da cabine para voar até uma camada",
"Library": "Biblioteca",
"Environment": "Meio ambiente",
"Imagery": "Imagens",
"Demographics": "Demografia",
"DATASET": "DADOS",
"TILES": "BLOCOS",
"PACK": "PACOTE",
"INCUBATING": "EM INCUBAÇÃO",
"COMMUNITY": "COMUNIDADE",
"VERIFIED": "VERIFICADO",
"ARCHIVED": "ARQUIVADO",
"Datasets": "Conjuntos de dados",
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
"{count} datasets": {
"one": "{count} conjunto de dados",
"many": "{count} de conjuntos de dados",
"other": "{count} conjuntos de dados"
},
"{count} tile layers": {
"one": "{count} camada de blocos",
"many": "{count} de camadas de blocos",
"other": "{count} camadas de blocos"
},
"{count} packs": {
"one": "{count} pacote",
"many": "{count} de pacotes",
"other": "{count} pacotes"
},
"Themes": "Temas",
"Added": "Adicionadas",
"{count} on the map": {
"one": "{count} no mapa",
"many": "{count} no mapa",
"other": "{count} no mapa"
},
"none yet": "nenhuma ainda",
"Map features": "Elementos do mapa",
"from {count} datasets": {
"one": "de {count} conjunto de dados",
"many": "de {count} de conjuntos de dados",
"other": "de {count} conjuntos de dados"
},
"switch on a dataset to load features": "ative um conjunto de dados para carregar elementos",
"Switched on by this instance · part of the CCTV layer": "Ativado nesta instância · parte da camada CCTV",
"Operator opt-in: set {env}=1 on the server": "Ativação pelo operador: defina {env}=1 no servidor",
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Ativação pelo operador: adicione {id} a GEV_LIBRARY_ENABLE no servidor",
"Turned off by this instance's operator": "Desativado pelo operador desta instância",
"Needs a provider key on the server": "Requer uma chave do provedor no servidor",
"Needs this app's server · not available in a static build": "Requer o servidor deste app · indisponível em uma build estática",
"Checking what this instance serves…": "Verificando o que esta instância oferece…",
"Regional": "Regional",
"Orthophoto": "Ortofoto",
"Satellite mosaic": "Mosaico de satélite",
"Oblique": "Oblíqua",
"Historic imagery": "Imagens históricas",
"True orthophoto": "Ortofoto verdadeira",
"Type": "Tipo",
"Resolution": "Resolução",
"Flown": "Voo",
"Updates": "Atualização",
"Ortho quality": "Qualidade da ortofoto",
"DATA LIBRARY": "BIBLIOTECA DE DADOS",
"Close data library": "Fechar a biblioteca de dados",
"Data Library at a glance": "A biblioteca de dados em resumo",
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fontes ao vivo além das camadas principais. Cada uma é obtida do seu provedor enquanto está ativa; nada é armazenado neste app. Adicione uma e ela aparecerá em Camadas de dados, em Biblioteca.",
"Name, provider or country": "Nome, provedor ou país",
"Theme": "Tema",
"All themes": "Todos os temas",
"Only what this instance serves": "Só o que esta instância oferece",
"OPT-IN": "OPCIONAL",
"KEY": "CHAVE",
"KEYLESS": "SEM CHAVE",
"ZOOM": "APROXIMAR",
"Zoom to {name}": "Ir para {name}",
"WORKING…": "PROCESSANDO…",
"ADD": "ADICIONAR",
"Remove {name} from Data Layers": "Remover {name} de Camadas de dados",
"Add {name} to Data Layers": "Adicionar {name} a Camadas de dados",
"CONFIRM · REMOVE ALL {count}": {
"one": "CONFIRMAR · REMOVER {count}",
"many": "CONFIRMAR · REMOVER TODAS AS {count}",
"other": "CONFIRMAR · REMOVER TODAS AS {count}"
},
"REMOVE ALL": "REMOVER TUDO",
"Confirm: remove every added entry from Data Layers": "Confirmar: remover de Camadas de dados todas as entradas adicionadas",
"Remove every added entry from Data Layers": "Remover de Camadas de dados todas as entradas adicionadas",
"Showing {shown} of {total}": "Mostrando {shown} de {total}",
"BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS"
} }
+129 -38
View File
@@ -128,7 +128,7 @@
"SRC {value}": "ИСТ. {value}", "SRC {value}": "ИСТ. {value}",
"{value} CONTEXT": "КОНТЕКСТ {value}", "{value} CONTEXT": "КОНТЕКСТ {value}",
"Focus fire detection {title}, {details}": "Фокус на очаге пожара {title}, {details}", "Focus fire detection {title}, {details}": "Фокус на очаге пожара {title}, {details}",
"{value} conf": "дост.: {value}", "{value} conf": "увер.: {value}",
"{value} ago": "{value} назад", "{value} ago": "{value} назад",
"sensor n/a": "сенсор н/д", "sensor n/a": "сенсор н/д",
"FIRE · {frp} MW": "ПОЖАР · {frp} МВт", "FIRE · {frp} MW": "ПОЖАР · {frp} МВт",
@@ -722,8 +722,6 @@
"Contact Context actions": "Действия контекста контакта", "Contact Context actions": "Действия контекста контакта",
"Available Space Missions": "Доступные космические миссии", "Available Space Missions": "Доступные космические миссии",
"LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ", "LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ",
"Space Missions unavailable: the mission index could not load. Try again later.": "Космические миссии недоступны: не удалось загрузить список миссий. Повторите попытку позже.",
"30-DAY MISSION INDEX UNAVAILABLE": "ИНДЕКС МИССИЙ ЗА 30 ДНЕЙ НЕДОСТУПЕН",
"Radio playback": "Воспроизведение радио", "Radio playback": "Воспроизведение радио",
"Radio volume": "Громкость радио", "Radio volume": "Громкость радио",
"STATION TAG": "ТЕГ СТАНЦИИ", "STATION TAG": "ТЕГ СТАНЦИИ",
@@ -2757,11 +2755,6 @@
"Zoom in for vector tile coverage": "Приблизьте карту, чтобы загрузить векторные тайлы", "Zoom in for vector tile coverage": "Приблизьте карту, чтобы загрузить векторные тайлы",
"Camera coverage unavailable": "Покрытие камер недоступно", "Camera coverage unavailable": "Покрытие камер недоступно",
"Hybrid": "Гибрид", "Hybrid": "Гибрид",
"Lines": "Линии",
"Dots": "Точки",
"Road lines colored by live flow: green to red": "Линии дорог по потоку онлайн: от зелёного к красному",
"Moving dots colored by live flow": "Движущиеся точки, окрашенные по потоку онлайн",
"Moving dots": "Движущиеся точки",
"TomTom roads only, each with its live flow": "Только дороги TomTom, у каждой — свой поток онлайн", "TomTom roads only, each with its live flow": "Только дороги TomTom, у каждой — свой поток онлайн",
"TomTom roads only, each with its live flow (needs a TomTom key)": "Только дороги TomTom, у каждой — свой поток онлайн (нужен ключ TomTom)", "TomTom roads only, each with its live flow (needs a TomTom key)": "Только дороги TomTom, у каждой — свой поток онлайн (нужен ключ TomTom)",
"OpenStreetMap roads, with TomTom flow matched when available": "Дороги OpenStreetMap; поток TomTom сопоставляется, где он есть", "OpenStreetMap roads, with TomTom flow matched when available": "Дороги OpenStreetMap; поток TomTom сопоставляется, где он есть",
@@ -3069,27 +3062,27 @@
"fire confidence filter|All": "Все", "fire confidence filter|All": "Все",
"fire confidence filter|Nominal+": "Средняя+", "fire confidence filter|Nominal+": "Средняя+",
"fire confidence filter|High": "Высокая", "fire confidence filter|High": "Высокая",
"Every detection — many of the strongest fires are rated low confidence": "Все очаги — многим сильнейшим пожарам присвоена низкая достоверность", "Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая",
"Nominal and high confidence detections": "Очаги средней и высокой достоверности", "Nominal and high confidence detections": "Детекции средней и высокой достоверности",
"High confidence detections only": "Только очаги высокой достоверности", "High confidence detections only": "Только детекции высокой достоверности",
"Fire intensity (FRP)": "Интенсивность (FRP)", "Fire intensity (FRP)": "Интенсивность (FRP)",
"≥ {value} MW": "≥ {value} МВт", "≥ {value} MW": "≥ {value} МВт",
"Confidence": "Достоверность", "Confidence": "Достоверность",
"Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты", "Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты",
"fire intensity filter|All": "Все", "fire intensity filter|All": "Все",
"Detection confidence": "Достоверность обнаружения", "Detection confidence": "Достоверность детекции",
"{shown} of {total} shown": "Показано {shown} из {total}", "{shown} of {total} shown": "показано {shown} из {total}",
"{lower} to {upper}": "От {lower} до {upper}", "{lower} to {upper}": "от {lower} до {upper}",
"Up to {upper}": "До {upper}", "Up to {upper}": "До {upper}",
"From {lower}": "От {lower}", "From {lower}": "От {lower}",
"Informational only · not for flight planning": "Только для информации · не для планирования полётов", "Informational only · not for flight planning": "Только для информации · не для планирования полётов",
"Unnamed zone": "Без названия", "Unnamed zone": "Зона без названия",
"Restricted airspace source unavailable": "Источник данных о зонах ограничения полётов недоступен", "Restricted airspace source unavailable": "Источник данных об ограниченном воздушном пространстве недоступен",
"Prohibited (P)": "Запретная зона (P)", "Prohibited (P)": "Запретная зона (P)",
"Restricted (R)": "Зона ограничения полётов (R)", "Restricted (R)": "Зона ограничения (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Только запретные зоны и зоны ограничения полётов FAA (воздушное пространство особого использования), без UAS Facility Map и ограничений полётов БАС. Только для информации — не заменяет официальные инструменты планирования полётов БАС (B4UFLY / LAANC).", "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Только запретные зоны и зоны ограничения полётов FAA (воздушное пространство особого использования), без UAS Facility Map и ограничений полётов БАС. Только для информации — не заменяет официальные инструменты планирования полётов БАС (B4UFLY / LAANC).",
"Prohibited": "Запретная зона", "Prohibited": "Запретная зона",
"Restricted": "Зона ограничения полётов", "Restricted": "Зона ограничения",
"Prohibited & Restricted Airspace": "Запретные зоны и зоны ограничения", "Prohibited & Restricted Airspace": "Запретные зоны и зоны ограничения",
"routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно", "routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно",
"route proxy error": "ошибка прокси маршрутов", "route proxy error": "ошибка прокси маршрутов",
@@ -3123,13 +3116,13 @@
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы", "Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
"Voice place search (needs an Overpass server)": "Голосовой поиск мест (нужен сервер Overpass)", "Voice place search (needs an Overpass server)": "Голосовой поиск мест (нужен сервер Overpass)",
"OSM Places": "Места OSM", "OSM Places": "Места OSM",
"state or province": "штат, провинция или область", "state or province": "штат или провинция",
"county or district": "округ или район", "county or district": "округ или район",
"neighbourhood": "район города", "neighbourhood": "район города",
"natural region": "природный регион", "natural region": "природный регион",
"Which {place}?": "Какой из вариантов «{place}»?", "Which {place}?": "Какой из вариантов «{place}»?",
"Which place?": "Какое место?", "Which place?": "Какое место?",
"Not drawn: {parts}": "Контур не нарисован: {parts}", "Not drawn: {parts}": "Не нарисовано: {parts}",
"approximate": "приблизительно", "approximate": "приблизительно",
"{pct}% cloud": "Облачность {pct}%", "{pct}% cloud": "Облачность {pct}%",
"Over {area}": "Область: {area}", "Over {area}": "Область: {area}",
@@ -3150,7 +3143,7 @@
"About {count} {kind}": "{kind}: около {count}", "About {count} {kind}": "{kind}: около {count}",
"At least {count} {kind}": "{kind}: не менее {count}", "At least {count} {kind}": "{kind}: не менее {count}",
"{count} {kind}": "{kind}: {count}", "{count} {kind}": "{kind}: {count}",
"In {area}": "Область: {area}", "In {area}": "Территория: {area}",
"Area around {place}": "Окрестности: {place}", "Area around {place}": "Окрестности: {place}",
"Area around landmark": "Окрестности ориентира", "Area around landmark": "Окрестности ориентира",
"Outline {place}": "Контур: {place}", "Outline {place}": "Контур: {place}",
@@ -3166,12 +3159,12 @@
"Searching OpenStreetMap": "Поиск в OpenStreetMap", "Searching OpenStreetMap": "Поиск в OpenStreetMap",
"voice area|Area": "Область", "voice area|Area": "Область",
"buffer sized to the place": "буферная зона по размеру места", "buffer sized to the place": "буферная зона по размеру места",
"enclosing mapped area": "область на карте, включающая ориентир", "enclosing mapped area": "охватывающая область на карте",
"the map was cleared while looking it up": "карта была очищена во время поиска", "the map was cleared while looking it up": "карта была очищена во время поиска",
"OpenStreetMap coverage varies by place": "Покрытие OpenStreetMap различается в разных местах", "OpenStreetMap coverage varies by place": "Покрытие OpenStreetMap различается в разных местах",
"the catalog covers the last 30 days": "каталог охватывает последние 30 дней", "the catalog covers the last 30 days": "каталог охватывает последние 30 дней",
"there are more": "есть и другие", "there are more": "есть ещё",
"{radius} m around the landmark": "{radius} м вокруг ориентира", "{radius} m around the landmark": "{radius} м вокруг объекта",
"{count} parts": { "{count} parts": {
"few": "Частей: {count}", "few": "Частей: {count}",
"many": "Частей: {count}", "many": "Частей: {count}",
@@ -3190,11 +3183,11 @@
"one": "Объединено дублей: {count}", "one": "Объединено дублей: {count}",
"other": "Объединено дублей: {count}" "other": "Объединено дублей: {count}"
}, },
"listed the first {limit}": "показаны первые результаты: {limit}", "listed the first {limit}": "показаны первые {limit}",
"counted over the bounding box of {area}, not its outline": "подсчитано по ограничивающему прямоугольнику ({area}), а не по контуру", "counted over the bounding box of {area}, not its outline": "подсчитано по ограничивающему прямоугольнику ({area}), а не по контуру",
"Search cancelled": "Поиск отменён", "Search cancelled": "Поиск отменён",
"Sea Marks": "Навигационные знаки", "Sea Marks": "Морские знаки",
"Ski Pistes": "Лыжные трассы", "Ski Pistes": "Горнолыжные трассы",
"Fly to Tokyo": "Перелети в Токио", "Fly to Tokyo": "Перелети в Токио",
"Show live flights": "Покажи рейсы в реальном времени", "Show live flights": "Покажи рейсы в реальном времени",
"Reset to globe view": "Вернись к виду всего глобуса", "Reset to globe view": "Вернись к виду всего глобуса",
@@ -3324,15 +3317,113 @@
"iNaturalist": "iNaturalist", "iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "Участники iNaturalist · лицензия у каждой записи", "iNaturalist contributors · licences per record": "Участники iNaturalist · лицензия у каждой записи",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Сэлиш) · лицензия у каждой записи", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Сэлиш) · лицензия у каждой записи",
"{days}d": "{days}д", "tiles unavailable": "тайлы недоступны",
"All markers": "Все метки", "Environment": "Окружающая среда",
"Show markers of every age": "Показать метки любого возраста", "Imagery": "Снимки",
"Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты сохранены", "DATASET": "НАБОР ДАННЫХ",
"event age filter|All": "Все", "TILES": "ТАЙЛЫ",
"Event age": "Возраст события", "PACK": "ПАКЕТ",
"Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены", "INCUBATING": "В ИНКУБАЦИИ",
"Close camera preview": "Закрыть просмотр камеры", "COMMUNITY": "СООБЩЕСТВО",
"Refresh map still": "Обновить кадр на карте", "VERIFIED": "ПРОВЕРЕНО",
"The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.", "ARCHIVED": "В АРХИВЕ",
"Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес." "Switched on by this instance · part of the CCTV layer": "Включено на этом экземпляре · часть слоя CCTV",
"Operator opt-in: set {env}=1 on the server": "Включает оператор: задайте {env}=1 на сервере",
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Включает оператор: добавьте {id} в GEV_LIBRARY_ENABLE на сервере",
"Turned off by this instance's operator": "Отключено оператором этого экземпляра",
"Needs a provider key on the server": "Нужен ключ провайдера на сервере",
"Needs this app's server · not available in a static build": "Нужен сервер приложения · недоступно в статической сборке",
"Checking what this instance serves…": "Проверяем, что доступно на этом экземпляре…",
"Regional": "Региональный",
"DATA LIBRARY": "БИБЛИОТЕКА ДАННЫХ",
"Close data library": "Закрыть библиотеку данных",
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Живые источники помимо основных слоёв. Каждый загружается у своего провайдера, пока он включён; приложение ничего не хранит. Добавьте источник, и он появится в «Слоях данных» в разделе «Библиотека».",
"Name, provider or country": "Название, провайдер или страна",
"Theme": "Тема",
"All themes": "Все темы",
"Only what this instance serves": "Только то, что доступно на этом экземпляре",
"OPT-IN": "ПО ВЫБОРУ",
"KEY": "КЛЮЧ",
"KEYLESS": "БЕЗ КЛЮЧА",
"WORKING…": "ВЫПОЛНЯЕТСЯ…",
"ADD": "ДОБАВИТЬ",
"Remove {name} from Data Layers": "Убрать {name} из «Слоёв данных»",
"Add {name} to Data Layers": "Добавить {name} в «Слои данных»",
"Library": "Библиотека",
"BROWSE DATA LIBRARY": "ОТКРЫТЬ БИБЛИОТЕКУ ДАННЫХ",
"DRAPED": "НАЛОЖЕНО",
"Zoom to layer": "Приблизить к слою",
"ZOOM": "ПРИБЛИЗИТЬ",
"Zoom to {name}": "Приблизить к «{name}»",
"CONFIRM · REMOVE ALL {count}": {
"one": "ПОДТВЕРДИТЬ · УБРАТЬ {count}",
"few": "ПОДТВЕРДИТЬ · УБРАТЬ ВСЕ {count}",
"many": "ПОДТВЕРДИТЬ · УБРАТЬ ВСЕ {count}",
"other": "ПОДТВЕРДИТЬ · УБРАТЬ ВСЕ {count}"
},
"REMOVE ALL": "УБРАТЬ ВСЁ",
"Confirm: remove every added entry from Data Layers": "Подтвердите: убрать все добавленные записи из «Слоёв данных»",
"Remove every added entry from Data Layers": "Убрать все добавленные записи из «Слоёв данных»",
"Exit cockpit to fly to a layer": "Выйдите из кабины, чтобы перелететь к слою",
"Zoom to layer · fly to where this layer has data": "Приблизить к слою · перелететь туда, где у слоя есть данные",
"Remove from Data Layers": "Убрать из «Слоёв данных»",
"Remove from Data Layers · add it again from the library": "Убрать из «Слоёв данных» · снова добавить можно из библиотеки",
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
"{count} datasets": {
"one": "{count} набор данных",
"few": "{count} набора данных",
"many": "{count} наборов данных",
"other": "{count} набора данных"
},
"{count} tile layers": {
"one": "{count} слой тайлов",
"few": "{count} слоя тайлов",
"many": "{count} слоёв тайлов",
"other": "{count} слоя тайлов"
},
"{count} packs": {
"one": "{count} пакет",
"few": "{count} пакета",
"many": "{count} пакетов",
"other": "{count} пакета"
},
"Added": "Добавлено",
"{count} on the map": {
"one": "{count} на карте",
"few": "{count} на карте",
"many": "{count} на карте",
"other": "{count} на карте"
},
"none yet": "пока ничего",
"from {count} datasets": {
"one": "из {count} набора данных",
"few": "из {count} наборов данных",
"many": "из {count} наборов данных",
"other": "из {count} набора данных"
},
"switch on a dataset to load features": "включите набор данных, чтобы загрузить объекты",
"Data Library at a glance": "Библиотека данных вкратце",
"Map features": "Объекты на карте",
"Showing {shown} of {total}": "Показано {shown} из {total}",
"Datasets": "Наборы данных",
"Themes": "Темы",
"zoom in to load this layer": "приблизьте, чтобы загрузить этот слой",
"Demographics": "Демография",
"Coverage outline": "Граница покрытия",
"Orthophoto": "Ортофото",
"Satellite mosaic": "Спутниковая мозаика",
"Oblique": "Перспективная съёмка",
"Historic imagery": "Исторические снимки",
"True orthophoto": "Истинное ортофото",
"Type": "Тип",
"Resolution": "Разрешение",
"Flown": "Съёмка",
"Updates": "Обновление",
"Ortho quality": "Качество ортофото",
"Below 250 m": "Ниже 250 м",
"250–500 m": "250–500 м",
"500–1,000 m": "500–1000 м",
"1,000–2,000 m": "1000–2000 м",
"2,000–3,000 m": "2000–3000 м",
"3,000 m and above": "3000 м и выше"
} }
+130 -39
View File
@@ -1118,11 +1118,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Спершу дороги TomTom; решту доповнює OpenStreetMap з імітацією потоку", "TomTom roads first; OpenStreetMap fills the rest, simulated": "Спершу дороги TomTom; решту доповнює OpenStreetMap з імітацією потоку",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Спершу дороги TomTom; решту доповнює OpenStreetMap з імітацією потоку (потрібен ключ TomTom)", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Спершу дороги TomTom; решту доповнює OpenStreetMap з імітацією потоку (потрібен ключ TomTom)",
"Hybrid": "Гібрид", "Hybrid": "Гібрид",
"Lines": "Лінії",
"Dots": "Точки",
"Road lines colored by live flow: green to red": "Лінії доріг за потоком онлайн: від зеленого до червоного",
"Moving dots colored by live flow": "Рухомі точки, забарвлені за потоком онлайн",
"Moving dots": "Рухомі точки",
"TomTom key unavailable": "Ключ TomTom недоступний", "TomTom key unavailable": "Ключ TomTom недоступний",
"TomTom daily budget reached": "Вичерпано денний ліміт TomTom", "TomTom daily budget reached": "Вичерпано денний ліміт TomTom",
"Monthly traffic allowance used": "Місячний ліміт даних про трафік вичерпано", "Monthly traffic allowance used": "Місячний ліміт даних про трафік вичерпано",
@@ -2880,8 +2875,6 @@
"AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ", "AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ",
"SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ", "SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ",
"LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ", "LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ",
"Space Missions unavailable: the mission index could not load. Try again later.": "Космічні місії недоступні: не вдалося завантажити список місій. Спробуйте пізніше.",
"30-DAY MISSION INDEX UNAVAILABLE": "ІНДЕКС МІСІЙ ЗА 30 ДНІВ НЕДОСТУПНИЙ",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР",
"Internet radio and local SDR": "Інтернет-радіо та локальний SDR", "Internet radio and local SDR": "Інтернет-радіо та локальний SDR",
"STATION TAG": "ТЕГ СТАНЦІЇ", "STATION TAG": "ТЕГ СТАНЦІЇ",
@@ -3070,19 +3063,19 @@
"Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати", "Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати",
"fire intensity filter|All": "Усі", "fire intensity filter|All": "Усі",
"Detection confidence": "Достовірність виявлення", "Detection confidence": "Достовірність виявлення",
"{shown} of {total} shown": "Показано {shown} з {total}", "{shown} of {total} shown": "показано {shown} з {total}",
"{lower} to {upper}": "Від {lower} до {upper}", "{lower} to {upper}": "від {lower} до {upper}",
"Up to {upper}": "До {upper}", "Up to {upper}": "До {upper}",
"From {lower}": "Від {lower}", "From {lower}": "Від {lower}",
"Informational only · not for flight planning": "Лише для довідки · не для планування польотів", "Informational only · not for flight planning": "Лише для довідки · не для планування польотів",
"Unnamed zone": "Без назви", "Unnamed zone": "Зона без назви",
"Restricted airspace source unavailable": "Джерело даних про зони обмеження польотів недоступне", "Restricted airspace source unavailable": "Джерело даних про обмежений повітряний простір недоступне",
"Prohibited (P)": "Заборонена зона (P)", "Prohibited (P)": "Заборонена зона (P)",
"Restricted (R)": "Зона обмеження (R)", "Restricted (R)": "Зона обмеження (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Лише заборонені зони та зони обмеження польотів FAA (повітряний простір особливого використання), без UAS Facility Map і обмежень польотів БпЛА. Лише для довідки — не замінює офіційні інструменти планування польотів БпЛА (B4UFLY / LAANC).", "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Лише заборонені зони та зони обмеження FAA (повітряний простір особливого використання), без UAS Facility Map і обмежень польотів БпЛА. Лише для довідки — не замінює офіційні інструменти планування польотів БпЛА (B4UFLY / LAANC).",
"Prohibited": "Заборонена зона", "Prohibited": "Заборонена зона",
"Restricted": "Зона обмеження", "Restricted": "Зона обмеження",
"Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження польотів", "Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження",
"Malformed cyclone snapshot": "Некоректний знімок циклонів", "Malformed cyclone snapshot": "Некоректний знімок циклонів",
"Cyclone request timed out": "Минув час очікування запиту циклонів", "Cyclone request timed out": "Минув час очікування запиту циклонів",
"scene|PLAY": "СТАРТ", "scene|PLAY": "СТАРТ",
@@ -3123,7 +3116,7 @@
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли", "Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
"Voice place search (needs an Overpass server)": "Голосовий пошук місць (потрібен сервер Overpass)", "Voice place search (needs an Overpass server)": "Голосовий пошук місць (потрібен сервер Overpass)",
"OSM Places": "Місця OSM", "OSM Places": "Місця OSM",
"state or province": "область, штат або провінція", "state or province": "штат або провінція",
"county or district": "округ або район", "county or district": "округ або район",
"neighbourhood": "район міста", "neighbourhood": "район міста",
"natural region": "природний регіон", "natural region": "природний регіон",
@@ -3154,9 +3147,9 @@
"Area around {place}": "Околиці: {place}", "Area around {place}": "Околиці: {place}",
"Area around landmark": "Околиці орієнтира", "Area around landmark": "Околиці орієнтира",
"Outline {place}": "Контур: {place}", "Outline {place}": "Контур: {place}",
"Outline area": "Контур території", "Outline area": "Контур області",
"Find {place}": "Пошук: {place}", "Find {place}": "Пошук: {place}",
"Find area": "Знайти територію", "Find area": "Знайти область",
"Find recent imagery": "Знайти свіжі знімки", "Find recent imagery": "Знайти свіжі знімки",
"Find {what}": "Пошук: {what}", "Find {what}": "Пошук: {what}",
"Find places": "Знайти місця", "Find places": "Знайти місця",
@@ -3164,14 +3157,14 @@
"Searching imagery catalog": "Пошук у каталозі знімків", "Searching imagery catalog": "Пошук у каталозі знімків",
"Loading imagery": "Завантаження знімків", "Loading imagery": "Завантаження знімків",
"Searching OpenStreetMap": "Пошук в OpenStreetMap", "Searching OpenStreetMap": "Пошук в OpenStreetMap",
"voice area|Area": "Територія", "voice area|Area": "Область",
"buffer sized to the place": "буферна зона за розміром місця", "buffer sized to the place": "буферна зона за розміром місця",
"enclosing mapped area": "нанесений контур, що охоплює орієнтир", "enclosing mapped area": "охопна область на карті",
"the map was cleared while looking it up": "карту було очищено під час пошуку", "the map was cleared while looking it up": "карту було очищено під час пошуку",
"OpenStreetMap coverage varies by place": "покриття OpenStreetMap різниться залежно від місця", "OpenStreetMap coverage varies by place": "Покриття OpenStreetMap відрізняється залежно від місця",
"the catalog covers the last 30 days": "каталог охоплює останні 30 днів", "the catalog covers the last 30 days": "каталог охоплює останні 30 днів",
"there are more": "є й інші", "there are more": "є ще",
"{radius} m around the landmark": "{radius} м навколо орієнтира", "{radius} m around the landmark": "{radius} м навколо об’єкта",
"{count} parts": { "{count} parts": {
"few": "Частин: {count}", "few": "Частин: {count}",
"many": "Частин: {count}", "many": "Частин: {count}",
@@ -3185,16 +3178,16 @@
"other": "Намальовано частин: {drawn} з {total}" "other": "Намальовано частин: {drawn} з {total}"
}, },
"{count} duplicate mappings merged": { "{count} duplicate mappings merged": {
"one": "Обʼєднано дублікатів: {count}", "few": "Об’єднано дублікатів: {count}",
"few": "Обʼєднано дублікатів: {count}", "many": "Об’єднано дублікатів: {count}",
"many": "Обʼєднано дублікатів: {count}", "one": "Об’єднано дублікатів: {count}",
"other": "Обʼєднано дублікатів: {count}" "other": "Об’єднано дублікатів: {count}"
}, },
"listed the first {limit}": "показано перші результати: {limit}", "listed the first {limit}": "показано перші {limit}",
"counted over the bounding box of {area}, not its outline": "підраховано за обмежувальним прямокутником ({area}), а не за контуром", "counted over the bounding box of {area}, not its outline": "підраховано за обмежувальним прямокутником ({area}), а не за контуром",
"Search cancelled": "Пошук скасовано", "Search cancelled": "Пошук скасовано",
"Sea Marks": "Навігаційні знаки", "Sea Marks": "Морські знаки",
"Ski Pistes": "Лижні траси", "Ski Pistes": "Гірськолижні траси",
"Fly to Tokyo": "Полети до Токіо", "Fly to Tokyo": "Полети до Токіо",
"Show live flights": "Покажи рейси онлайн", "Show live flights": "Покажи рейси онлайн",
"Reset to globe view": "Поверни вигляд глобуса", "Reset to globe view": "Поверни вигляд глобуса",
@@ -3324,15 +3317,113 @@
"iNaturalist": "iNaturalist", "iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "Учасники iNaturalist · ліцензія в кожного запису", "iNaturalist contributors · licences per record": "Учасники iNaturalist · ліцензія в кожного запису",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Учасники iNaturalist · Acartia {count} (море Селіш) · ліцензія в кожного запису", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Учасники iNaturalist · Acartia {count} (море Селіш) · ліцензія в кожного запису",
"{days}d": "{days}д", "tiles unavailable": "тайли недоступні",
"All markers": "Усі позначки", "zoom in to load this layer": "наблизьте, щоб завантажити цей шар",
"Show markers of every age": "Показати позначки будь-якого віку", "Zoom to layer": "Наблизити до шару",
"Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн.; позначки без дати збережено", "Zoom to layer · fly to where this layer has data": "Наблизити до шару · перелетіти туди, де цей шар має дані",
"event age filter|All": "Усі", "Remove from Data Layers": "Вилучити з «Шарів даних»",
"Event age": "Вік події", "Remove from Data Layers · add it again from the library": "Вилучити з «Шарів даних» · можна знову додати з бібліотеки",
"Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено", "Coverage outline": "Контур покриття",
"Close camera preview": "Закрити перегляд камери", "DRAPED": "НАКЛАДЕНО",
"Refresh map still": "Оновити кадр на карті", "Below 250 m": "Нижче 250 м",
"The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.", "250–500 m": "250–500 м",
"Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу." "500–1,000 m": "500–1000 м",
"1,000–2,000 m": "1000–2000 м",
"2,000–3,000 m": "2000–3000 м",
"3,000 m and above": "3000 м і вище",
"Exit cockpit to fly to a layer": "Вийдіть із кабіни, щоб перелетіти до шару",
"Library": "Бібліотека",
"Environment": "Довкілля",
"Imagery": "Знімки",
"Demographics": "Демографія",
"DATASET": "НАБІР ДАНИХ",
"TILES": "ТАЙЛИ",
"PACK": "ПАКЕТ",
"INCUBATING": "НА ІНКУБАЦІЇ",
"COMMUNITY": "СПІЛЬНОТА",
"VERIFIED": "ПЕРЕВІРЕНО",
"ARCHIVED": "В АРХІВІ",
"Datasets": "Набори даних",
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
"{count} datasets": {
"one": "{count} набір даних",
"few": "{count} набори даних",
"many": "{count} наборів даних",
"other": "{count} набору даних"
},
"{count} tile layers": {
"one": "{count} шар тайлів",
"few": "{count} шари тайлів",
"many": "{count} шарів тайлів",
"other": "{count} шару тайлів"
},
"{count} packs": {
"one": "{count} пакет",
"few": "{count} пакети",
"many": "{count} пакетів",
"other": "{count} пакета"
},
"Themes": "Теми",
"Added": "Додані",
"{count} on the map": {
"one": "{count} на карті",
"few": "{count} на карті",
"many": "{count} на карті",
"other": "{count} на карті"
},
"none yet": "поки жодного",
"Map features": "Об’єкти карти",
"from {count} datasets": {
"one": "з {count} набору даних",
"few": "з {count} наборів даних",
"many": "з {count} наборів даних",
"other": "з {count} набору даних"
},
"switch on a dataset to load features": "увімкніть набір даних, щоб завантажити об’єкти",
"Switched on by this instance · part of the CCTV layer": "Увімкнено цим екземпляром · частина шару CCTV",
"Operator opt-in: set {env}=1 on the server": "Увімкнення оператором: задайте {env}=1 на сервері",
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Увімкнення оператором: додайте {id} до GEV_LIBRARY_ENABLE на сервері",
"Turned off by this instance's operator": "Вимкнено оператором цього екземпляра",
"Needs a provider key on the server": "Потрібен ключ постачальника на сервері",
"Needs this app's server · not available in a static build": "Потрібен сервер цього застосунку · недоступно в статичній збірці",
"Checking what this instance serves…": "Перевірка, що надає цей екземпляр…",
"Regional": "Регіональні",
"Orthophoto": "Ортофото",
"Satellite mosaic": "Супутникова мозаїка",
"Oblique": "Перспективна зйомка",
"Historic imagery": "Історичні знімки",
"True orthophoto": "Справжнє ортофото",
"Type": "Тип",
"Resolution": "Роздільність",
"Flown": "Зйомка",
"Updates": "Оновлення",
"Ortho quality": "Якість орто",
"DATA LIBRARY": "БІБЛІОТЕКА ДАНИХ",
"Close data library": "Закрити бібліотеку даних",
"Data Library at a glance": "Бібліотека даних коротко",
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Живі джерела поза основними шарами. Кожне завантажується від свого постачальника, поки увімкнене; цей застосунок нічого не зберігає. Додайте джерело, і воно з’явиться в «Шарах даних» у розділі «Бібліотека».",
"Name, provider or country": "Назва, постачальник або країна",
"Theme": "Тема",
"All themes": "Усі теми",
"Only what this instance serves": "Лише те, що надає цей екземпляр",
"OPT-IN": "ЗА ЗГОДОЮ",
"KEY": "КЛЮЧ",
"KEYLESS": "БЕЗ КЛЮЧА",
"ZOOM": "НАБЛИЗИТИ",
"Zoom to {name}": "Наблизити до {name}",
"WORKING…": "ОБРОБКА…",
"ADD": "ДОДАТИ",
"Remove {name} from Data Layers": "Вилучити {name} з «Шарів даних»",
"Add {name} to Data Layers": "Додати {name} до «Шарів даних»",
"CONFIRM · REMOVE ALL {count}": {
"one": "ПІДТВЕРДИТИ · ВИЛУЧИТИ {count}",
"few": "ПІДТВЕРДИТИ · ВИЛУЧИТИ ВСІ {count}",
"many": "ПІДТВЕРДИТИ · ВИЛУЧИТИ ВСІ {count}",
"other": "ПІДТВЕРДИТИ · ВИЛУЧИТИ ВСІ {count}"
},
"REMOVE ALL": "ВИЛУЧИТИ ВСЕ",
"Confirm: remove every added entry from Data Layers": "Підтвердити: вилучити всі додані записи з «Шарів даних»",
"Remove every added entry from Data Layers": "Вилучити всі додані записи з «Шарів даних»",
"Showing {shown} of {total}": "Показано {shown} з {total}",
"BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ"
} }
+111 -38
View File
@@ -1047,11 +1047,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况", "TomTom roads first; OpenStreetMap fills the rest, simulated": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况(需要 TomTom 密钥)", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况(需要 TomTom 密钥)",
"Hybrid": "混合", "Hybrid": "混合",
"Lines": "线条",
"Dots": "圆点",
"Road lines colored by live flow: green to red": "按实时路况着色的道路线条:从绿到红",
"Moving dots colored by live flow": "按实时路况着色的移动圆点",
"Moving dots": "移动圆点",
"TomTom key unavailable": "TomTom 密钥不可用", "TomTom key unavailable": "TomTom 密钥不可用",
"TomTom daily budget reached": "已达 TomTom 每日配额", "TomTom daily budget reached": "已达 TomTom 每日配额",
"Monthly traffic allowance used": "本月交通流量额度已用完", "Monthly traffic allowance used": "本月交通流量额度已用完",
@@ -2567,8 +2562,6 @@
"AVAILABLE MISSIONS": "可用任务", "AVAILABLE MISSIONS": "可用任务",
"SELECT A MISSION TO INSPECT": "选择一项任务以查看", "SELECT A MISSION TO INSPECT": "选择一项任务以查看",
"LOADING 30-DAY MISSION INDEX": "正在加载 30 天任务索引", "LOADING 30-DAY MISSION INDEX": "正在加载 30 天任务索引",
"Space Missions unavailable: the mission index could not load. Try again later.": "太空任务不可用:无法加载任务索引。请稍后重试。",
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任务索引不可用",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 预览 · ENTER / 空格键选择", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 预览 · ENTER / 空格键选择",
"Internet radio and local SDR": "网络电台与本地 SDR", "Internet radio and local SDR": "网络电台与本地 SDR",
"STATION TAG": "电台标签", "STATION TAG": "电台标签",
@@ -2857,26 +2850,26 @@
}, },
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。", "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。",
"fire confidence filter|All": "全部", "fire confidence filter|All": "全部",
"fire confidence filter|Nominal+": "中+", "fire confidence filter|Nominal+": "标称+",
"fire confidence filter|High": "高", "fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "所有火点探测——许多强度最高的火点被评为低置信度", "Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低",
"Nominal and high confidence detections": "中、高置信度的火点探测", "Nominal and high confidence detections": "标称和高置信度的检测",
"High confidence detections only": "仅高置信度的火点探测", "High confidence detections only": "仅高置信度的检测",
"Fire intensity (FRP)": "火势强度(FRP)", "Fire intensity (FRP)": "火势强度 (FRP)",
"≥ {value} MW": "≥ {value} MW", "≥ {value} MW": "≥ {value} MW",
"Confidence": "置信度", "Confidence": "置信度",
"Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)", "Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)",
"fire intensity filter|All": "全部", "fire intensity filter|All": "全部",
"Detection confidence": "探测置信度", "Detection confidence": "检测置信度",
"{shown} of {total} shown": "显示 {shown} / {total}", "{shown} of {total} shown": "显示 {shown} / {total}",
"{lower} to {upper}": "{lower} 至 {upper}", "{lower} to {upper}": "{lower} 至 {upper}",
"Up to {upper}": "最高 {upper}", "Up to {upper}": "最高 {upper}",
"From {lower}": "最低 {lower}", "From {lower}": "自 {lower} 起",
"Informational only · not for flight planning": "仅供参考 · 不可用于飞行规划", "Informational only · not for flight planning": "仅供参考 · 不可用于飞行规划",
"Unnamed zone": "未命名空域", "Unnamed zone": "未命名区域",
"Restricted airspace source unavailable": "禁区与限制区空域数据源不可用", "Restricted airspace source unavailable": "受限空域数据源不可用",
"Prohibited (P)": "禁区(P)", "Prohibited (P)": "禁区 (P)",
"Restricted (R)": "限制区(R)", "Restricted (R)": "限制区 (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "仅包含 FAA 禁区和限制区特殊用途空域,不含 UAS Facility Map 和无人机飞行限制。仅供参考,不能替代官方无人机飞行规划工具(B4UFLY / LAANC)。", "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "仅包含 FAA 禁区和限制区特殊用途空域,不含 UAS Facility Map 和无人机飞行限制。仅供参考,不能替代官方无人机飞行规划工具(B4UFLY / LAANC)。",
"Prohibited": "禁区", "Prohibited": "禁区",
"Restricted": "限制区", "Restricted": "限制区",
@@ -2922,7 +2915,7 @@
"Not drawn: {parts}": "未绘制:{parts}", "Not drawn: {parts}": "未绘制:{parts}",
"approximate": "近似", "approximate": "近似",
"{pct}% cloud": "云量 {pct}%", "{pct}% cloud": "云量 {pct}%",
"Over {area}": "覆盖 {area}", "Over {area}": "{area} 上空",
"{count} of {total} acquisitions match": { "{count} of {total} acquisitions match": {
"other": "匹配影像:{count}/{total}" "other": "匹配影像:{count}/{total}"
}, },
@@ -2937,36 +2930,36 @@
"In {area}": "位于 {area}", "In {area}": "位于 {area}",
"Area around {place}": "{place} 周边区域", "Area around {place}": "{place} 周边区域",
"Area around landmark": "地标周边区域", "Area around landmark": "地标周边区域",
"Outline {place}": "绘制 {place} 轮廓", "Outline {place}": "勾勒 {place} 轮廓",
"Outline area": "绘制区域轮廓", "Outline area": "勾勒区域轮廓",
"Find {place}": "查找 {place}", "Find {place}": "查找 {place}",
"Find area": "查找区域", "Find area": "查找区域",
"Find recent imagery": "查找近期影像", "Find recent imagery": "查找近期影像",
"Find {what}": "查找 {what}", "Find {what}": "查找 {what}",
"Find places": "查找地点", "Find places": "查找地点",
"Drawing outline": "正在地图上绘制轮廓", "Drawing outline": "正在勾勒轮廓",
"Searching imagery catalog": "正在搜索影像目录", "Searching imagery catalog": "正在搜索影像目录",
"Loading imagery": "正在加载影像", "Loading imagery": "正在加载影像",
"Searching OpenStreetMap": "正在搜索 OpenStreetMap", "Searching OpenStreetMap": "正在搜索 OpenStreetMap",
"voice area|Area": "区域", "voice area|Area": "区域",
"buffer sized to the place": "按地点大小设置的缓冲区", "buffer sized to the place": "按地点大小设置的缓冲区",
"enclosing mapped area": "包含该地标的地图标注区域", "enclosing mapped area": "包含该地点的地图区域",
"the map was cleared while looking it up": "查找期间地图已清空", "the map was cleared while looking it up": "查找期间地图已清空",
"OpenStreetMap coverage varies by place": "OpenStreetMap 的覆盖程度因地而异", "OpenStreetMap coverage varies by place": "OpenStreetMap 的覆盖程度因地而异",
"the catalog covers the last 30 days": "目录涵盖最近 30 天", "the catalog covers the last 30 days": "目录涵盖最近 30 天",
"there are more": "还有更多", "there are more": "还有更多",
"{radius} m around the landmark": "地标周围 {radius} m", "{radius} m around the landmark": "地标周围 {radius} 米",
"{count} parts": { "{count} parts": {
"other": "{count} 个部分" "other": "部分数:{count}"
}, },
"{drawn} of {total} parts drawn": { "{drawn} of {total} parts drawn": {
"other": "已绘制 {drawn}/{total} 个部分" "other": "已绘制部分:{drawn}/{total}"
}, },
"{count} duplicate mappings merged": { "{count} duplicate mappings merged": {
"other": "已合并重复项:{count}" "other": "已合并重复项:{count}"
}, },
"listed the first {limit}": "已列出前 {limit} 个", "listed the first {limit}": "已列出前 {limit} 个",
"counted over the bounding box of {area}, not its outline": "按 {area} 的外接矩形计数,而非其轮廓", "counted over the bounding box of {area}, not its outline": "按外接矩形({area})计数,而非其轮廓",
"Search cancelled": "搜索已取消", "Search cancelled": "搜索已取消",
"Sea Marks": "航标", "Sea Marks": "航标",
"Ski Pistes": "滑雪道", "Ski Pistes": "滑雪道",
@@ -3099,15 +3092,95 @@
"iNaturalist": "iNaturalist", "iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "iNaturalist 贡献者 · 各记录单独授权", "iNaturalist contributors · licences per record": "iNaturalist 贡献者 · 各记录单独授权",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海) · 各记录单独授权", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海) · 各记录单独授权",
"{days}d": "{days}天", "tiles unavailable": "瓦片不可用",
"All markers": "所有标记", "zoom in to load this layer": "放大以加载此图层",
"Show markers of every age": "显示所有时间的标记", "Zoom to layer": "缩放至图层",
"Maximum event age: {days}d; undated markers are kept": "事件最长保留时间:{days}天;保留无日期标记", "Zoom to layer · fly to where this layer has data": "缩放至图层 · 飞往此图层有数据的区域",
"event age filter|All": "全部", "Remove from Data Layers": "从数据图层中移除",
"Event age": "事件时间", "Remove from Data Layers · add it again from the library": "从数据图层中移除 · 可从数据资源库重新添加",
"Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记", "Coverage outline": "覆盖范围轮廓",
"Close camera preview": "关闭摄像头预览", "DRAPED": "贴地",
"Refresh map still": "刷新地图静止画面", "Below 250 m": "低于 250 m",
"The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。", "250–500 m": "250–500 m",
"Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。" "500–1,000 m": "500–1,000 m",
"1,000–2,000 m": "1,000–2,000 m",
"2,000–3,000 m": "2,000–3,000 m",
"3,000 m and above": "3,000 m 及以上",
"Exit cockpit to fly to a layer": "退出驾驶舱后才能飞往图层",
"Library": "资源库",
"Environment": "环境",
"Imagery": "影像",
"Demographics": "人口统计",
"DATASET": "数据集",
"TILES": "瓦片",
"PACK": "数据包",
"INCUBATING": "孵化中",
"COMMUNITY": "社区",
"VERIFIED": "已验证",
"ARCHIVED": "已归档",
"Datasets": "数据集",
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
"{count} datasets": {
"other": "{count} 个数据集"
},
"{count} tile layers": {
"other": "{count} 个瓦片图层"
},
"{count} packs": {
"other": "{count} 个数据包"
},
"Themes": "主题",
"Added": "已添加",
"{count} on the map": {
"other": "地图上 {count} 个"
},
"none yet": "暂无",
"Map features": "地图要素",
"from {count} datasets": {
"other": "来自 {count} 个数据集"
},
"switch on a dataset to load features": "开启一个数据集以加载要素",
"Switched on by this instance · part of the CCTV layer": "已由此实例开启 · 属于 CCTV 图层",
"Operator opt-in: set {env}=1 on the server": "运营者启用:在服务器上设置 {env}=1",
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "运营者启用:在服务器上将 {id} 添加到 GEV_LIBRARY_ENABLE",
"Turned off by this instance's operator": "已被此实例的运营者关闭",
"Needs a provider key on the server": "需要在服务器上配置提供方密钥",
"Needs this app's server · not available in a static build": "需要此应用的服务器 · 静态构建中不可用",
"Checking what this instance serves…": "正在检查此实例提供的内容…",
"Regional": "区域",
"Orthophoto": "正射影像",
"Satellite mosaic": "卫星镶嵌图",
"Oblique": "倾斜影像",
"Historic imagery": "历史影像",
"True orthophoto": "真正射影像",
"Type": "类型",
"Resolution": "分辨率",
"Flown": "航摄时间",
"Updates": "更新频率",
"Ortho quality": "正射质量",
"DATA LIBRARY": "数据资源库",
"Close data library": "关闭数据资源库",
"Data Library at a glance": "数据资源库概览",
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "核心图层之外的实时数据源。每个数据源开启时直接从其提供方获取;本应用不存储任何数据。添加后,它会出现在数据图层的“资源库”分组中。",
"Name, provider or country": "名称、提供方或国家",
"Theme": "主题",
"All themes": "全部主题",
"Only what this instance serves": "仅显示此实例提供的内容",
"OPT-IN": "需启用",
"KEY": "需密钥",
"KEYLESS": "免密钥",
"ZOOM": "缩放",
"Zoom to {name}": "缩放至 {name}",
"WORKING…": "处理中…",
"ADD": "添加",
"Remove {name} from Data Layers": "从数据图层中移除 {name}",
"Add {name} to Data Layers": "将 {name} 添加到数据图层",
"CONFIRM · REMOVE ALL {count}": {
"other": "确认 · 全部移除 {count} 项"
},
"REMOVE ALL": "全部移除",
"Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目",
"Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目",
"Showing {shown} of {total}": "显示 {shown} / {total}",
"BROWSE DATA LIBRARY": "浏览数据资源库"
} }
+115 -42
View File
@@ -1042,11 +1042,6 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況", "TomTom roads first; OpenStreetMap fills the rest, simulated": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況(需要 TomTom 密鑰)", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況(需要 TomTom 密鑰)",
"Hybrid": "混合", "Hybrid": "混合",
"Lines": "線條",
"Dots": "圓點",
"Road lines colored by live flow: green to red": "依即時路況著色的道路線條:由綠到紅",
"Moving dots colored by live flow": "依即時路況著色的移動圓點",
"Moving dots": "移動圓點",
"TomTom key unavailable": "TomTom 密鑰不可用", "TomTom key unavailable": "TomTom 密鑰不可用",
"TomTom daily budget reached": "已達 TomTom 每日配額", "TomTom daily budget reached": "已達 TomTom 每日配額",
"Monthly traffic allowance used": "本月交通流量額度已用完", "Monthly traffic allowance used": "本月交通流量額度已用完",
@@ -2555,8 +2550,6 @@
"AVAILABLE MISSIONS": "可用任務", "AVAILABLE MISSIONS": "可用任務",
"SELECT A MISSION TO INSPECT": "選擇一項任務以查看", "SELECT A MISSION TO INSPECT": "選擇一項任務以查看",
"LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引", "LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引",
"Space Missions unavailable: the mission index could not load. Try again later.": "太空任務無法使用:無法載入任務索引。請稍後再試。",
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任務索引不可用",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇",
"Internet radio and local SDR": "網絡電臺與本地 SDR", "Internet radio and local SDR": "網絡電臺與本地 SDR",
"STATION TAG": "電臺標籤", "STATION TAG": "電臺標籤",
@@ -2860,16 +2853,16 @@
"route proxy error": "路線代理出錯", "route proxy error": "路線代理出錯",
"{lower} to {upper}": "{lower} 至 {upper}", "{lower} to {upper}": "{lower} 至 {upper}",
"Up to {upper}": "最高 {upper}", "Up to {upper}": "最高 {upper}",
"From {lower}": "最低 {lower}", "From {lower}": "自 {lower} 起",
"Informational only · not for flight planning": "僅供參考 · 不可用於飛行規劃", "Informational only · not for flight planning": "僅供參考 · 不可用於飛行規劃",
"Unnamed zone": "未命名空域", "Unnamed zone": "未命名區域",
"Restricted airspace source unavailable": "禁區與限制區空域數據源不可用", "Restricted airspace source unavailable": "限制空域資料來源無法使用",
"Prohibited (P)": "禁區(P)", "Prohibited (P)": "禁航區 (P)",
"Restricted (R)": "限制區(R)", "Restricted (R)": "限航區 (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "僅包含 FAA 禁區和限制區特殊用途空域,不含 UAS Facility Map 和無人機飛行限制。僅供參考,不能替代官方無人機飛行規劃工具(B4UFLY / LAANC)。", "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "僅包含 FAA 禁航區與限航區特殊用途空域,不含 UAS Facility Map 與無人機飛行限制。僅供參考,不能取代官方無人機飛行規劃工具(B4UFLY / LAANC)。",
"Prohibited": "禁區", "Prohibited": "禁航區",
"Restricted": "限制區", "Restricted": "限航區",
"Prohibited & Restricted Airspace": "禁區與限制區空域", "Prohibited & Restricted Airspace": "禁航區與限航區空域",
"AUTHORING": "創作", "AUTHORING": "創作",
"Create and edit scenes": "創建和編輯場景", "Create and edit scenes": "創建和編輯場景",
"SCENE": "場景", "SCENE": "場景",
@@ -2896,17 +2889,17 @@
"Left panels": "左側面板", "Left panels": "左側面板",
"Right panels": "右側面板", "Right panels": "右側面板",
"fire confidence filter|All": "全部", "fire confidence filter|All": "全部",
"fire confidence filter|Nominal+": "中+", "fire confidence filter|Nominal+": "標稱+",
"fire confidence filter|High": "高", "fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "所有火點探測——許多強度最高的火點被評為低置信度", "Every detection — many of the strongest fires are rated low confidence": "所有檢測——許多最強的火點置信度為低",
"Nominal and high confidence detections": "中、高置信度的火點探測", "Nominal and high confidence detections": "標稱和高置信度的檢測",
"High confidence detections only": "僅高置信度的火點探測", "High confidence detections only": "僅高置信度的檢測",
"Fire intensity (FRP)": "火勢強度(FRP)", "Fire intensity (FRP)": "火勢強度 (FRP)",
"≥ {value} MW": "≥ {value} MW", "≥ {value} MW": "≥ {value} MW",
"Confidence": "置信度", "Confidence": "置信度",
"Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)", "Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)",
"fire intensity filter|All": "全部", "fire intensity filter|All": "全部",
"Detection confidence": "探測置信度", "Detection confidence": "檢測置信度",
"{shown} of {total} shown": "顯示 {shown} / {total}", "{shown} of {total} shown": "顯示 {shown} / {total}",
"UTILITIES": "工具", "UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置", "Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
@@ -2922,7 +2915,7 @@
"Not drawn: {parts}": "未繪製:{parts}", "Not drawn: {parts}": "未繪製:{parts}",
"approximate": "近似", "approximate": "近似",
"{pct}% cloud": "雲量 {pct}%", "{pct}% cloud": "雲量 {pct}%",
"Over {area}": "覆蓋 {area}", "Over {area}": "{area} 上空",
"{count} of {total} acquisitions match": { "{count} of {total} acquisitions match": {
"other": "匹配影像:{count}/{total}" "other": "匹配影像:{count}/{total}"
}, },
@@ -2937,36 +2930,36 @@
"In {area}": "位於 {area}", "In {area}": "位於 {area}",
"Area around {place}": "{place} 周邊區域", "Area around {place}": "{place} 周邊區域",
"Area around landmark": "地標周邊區域", "Area around landmark": "地標周邊區域",
"Outline {place}": "繪製 {place} 輪廓", "Outline {place}": "勾勒 {place} 輪廓",
"Outline area": "繪製區域輪廓", "Outline area": "勾勒區域輪廓",
"Find {place}": "查找 {place}", "Find {place}": "查找 {place}",
"Find area": "查找區域", "Find area": "查找區域",
"Find recent imagery": "查找近期影像", "Find recent imagery": "查找近期影像",
"Find {what}": "查找 {what}", "Find {what}": "查找 {what}",
"Find places": "查找地點", "Find places": "查找地點",
"Drawing outline": "正在地圖上繪製輪廓", "Drawing outline": "正在勾勒輪廓",
"Searching imagery catalog": "正在搜索影像目錄", "Searching imagery catalog": "正在搜索影像目錄",
"Loading imagery": "正在加載影像", "Loading imagery": "正在加載影像",
"Searching OpenStreetMap": "正在搜索 OpenStreetMap", "Searching OpenStreetMap": "正在搜索 OpenStreetMap",
"voice area|Area": "區域", "voice area|Area": "區域",
"buffer sized to the place": "按地點大小設置的緩衝區", "buffer sized to the place": "按地點大小設置的緩衝區",
"enclosing mapped area": "包含該地標的地圖標註區域", "enclosing mapped area": "包含該地點的地圖區域",
"the map was cleared while looking it up": "查找期間地圖已清空", "the map was cleared while looking it up": "查找期間地圖已清空",
"OpenStreetMap coverage varies by place": "OpenStreetMap 的覆蓋程度因地而異", "OpenStreetMap coverage varies by place": "OpenStreetMap 的覆蓋程度因地而異",
"the catalog covers the last 30 days": "目錄涵蓋最近 30 天", "the catalog covers the last 30 days": "目錄涵蓋最近 30 天",
"there are more": "還有更多", "there are more": "還有更多",
"{radius} m around the landmark": "地標周圍 {radius} m", "{radius} m around the landmark": "地標周圍 {radius} 米",
"{count} parts": { "{count} parts": {
"other": "{count} 個部分" "other": "部分數:{count}"
}, },
"{drawn} of {total} parts drawn": { "{drawn} of {total} parts drawn": {
"other": "已繪製 {drawn}/{total} 個部分" "other": "已繪製部分:{drawn}/{total}"
}, },
"{count} duplicate mappings merged": { "{count} duplicate mappings merged": {
"other": "已合併重複項:{count}" "other": "已合併重複項:{count}"
}, },
"listed the first {limit}": "已列出前 {limit} 個", "listed the first {limit}": "已列出前 {limit} 個",
"counted over the bounding box of {area}, not its outline": "按 {area} 的外接矩形計數,而非其輪廓", "counted over the bounding box of {area}, not its outline": "按外接矩形({area})計數,而非其輪廓",
"Search cancelled": "搜索已取消", "Search cancelled": "搜索已取消",
"Sea Marks": "航標", "Sea Marks": "航標",
"Ski Pistes": "滑雪道", "Ski Pistes": "滑雪道",
@@ -3099,15 +3092,95 @@
"iNaturalist": "iNaturalist", "iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "iNaturalist 貢獻者 · 各紀錄個別授權", "iNaturalist contributors · licences per record": "iNaturalist 貢獻者 · 各紀錄個別授權",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海) · 各紀錄個別授權", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海) · 各紀錄個別授權",
"{days}d": "{days}天", "tiles unavailable": "圖磚無法使用",
"All markers": "所有標記", "zoom in to load this layer": "放大以載入此圖層",
"Show markers of every age": "顯示所有時間的標記", "Zoom to layer": "縮放至圖層",
"Maximum event age: {days}d; undated markers are kept": "事件最長保留時間:{days}天;保留無日期標記", "Zoom to layer · fly to where this layer has data": "縮放至圖層 · 飛往此圖層有資料的區域",
"event age filter|All": "全部", "Remove from Data Layers": "從數據圖層中移除",
"Event age": "事件時間", "Remove from Data Layers · add it again from the library": "從數據圖層中移除 · 可從數據資源庫重新加入",
"Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記", "Coverage outline": "涵蓋範圍輪廓",
"Close camera preview": "關閉攝影機預覽", "DRAPED": "貼地",
"Refresh map still": "重新整理地圖靜態畫面", "Below 250 m": "低於 250 m",
"The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。", "250–500 m": "250–500 m",
"Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。" "500–1,000 m": "500–1,000 m",
"1,000–2,000 m": "1,000–2,000 m",
"2,000–3,000 m": "2,000–3,000 m",
"3,000 m and above": "3,000 m 以上",
"Exit cockpit to fly to a layer": "離開駕駛艙後才能飛往圖層",
"Library": "資源庫",
"Environment": "環境",
"Imagery": "影像",
"Demographics": "人口統計",
"DATASET": "資料集",
"TILES": "圖磚",
"PACK": "資料包",
"INCUBATING": "孵化中",
"COMMUNITY": "社群",
"VERIFIED": "已驗證",
"ARCHIVED": "已封存",
"Datasets": "資料集",
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
"{count} datasets": {
"other": "{count} 個資料集"
},
"{count} tile layers": {
"other": "{count} 個圖磚圖層"
},
"{count} packs": {
"other": "{count} 個資料包"
},
"Themes": "主題",
"Added": "已加入",
"{count} on the map": {
"other": "地圖上 {count} 個"
},
"none yet": "尚無",
"Map features": "地圖圖徵",
"from {count} datasets": {
"other": "來自 {count} 個資料集"
},
"switch on a dataset to load features": "開啟一個資料集以載入圖徵",
"Switched on by this instance · part of the CCTV layer": "已由此執行個體開啟 · 屬於 CCTV 圖層",
"Operator opt-in: set {env}=1 on the server": "營運者啟用:在伺服器上設定 {env}=1",
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "營運者啟用:在伺服器上將 {id} 加入 GEV_LIBRARY_ENABLE",
"Turned off by this instance's operator": "已被此執行個體的營運者關閉",
"Needs a provider key on the server": "需要在伺服器上設定提供者金鑰",
"Needs this app's server · not available in a static build": "需要此應用程式的伺服器 · 靜態建置中無法使用",
"Checking what this instance serves…": "正在檢查此執行個體提供的內容…",
"Regional": "區域",
"Orthophoto": "正射影像",
"Satellite mosaic": "衛星鑲嵌圖",
"Oblique": "傾斜影像",
"Historic imagery": "歷史影像",
"True orthophoto": "真正射影像",
"Type": "類型",
"Resolution": "解析度",
"Flown": "航拍時間",
"Updates": "更新頻率",
"Ortho quality": "正射品質",
"DATA LIBRARY": "數據資源庫",
"Close data library": "關閉數據資源庫",
"Data Library at a glance": "數據資源庫概覽",
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "核心圖層以外的即時資料來源。每個來源開啟時直接從其提供者取得;本應用程式不儲存任何資料。加入後,它會出現在數據圖層的「資源庫」分組中。",
"Name, provider or country": "名稱、提供者或國家",
"Theme": "主題",
"All themes": "所有主題",
"Only what this instance serves": "僅顯示此執行個體提供的內容",
"OPT-IN": "需啟用",
"KEY": "需金鑰",
"KEYLESS": "免金鑰",
"ZOOM": "縮放",
"Zoom to {name}": "縮放至 {name}",
"WORKING…": "處理中…",
"ADD": "加入",
"Remove {name} from Data Layers": "從數據圖層中移除 {name}",
"Add {name} to Data Layers": "將 {name} 加入數據圖層",
"CONFIRM · REMOVE ALL {count}": {
"other": "確認 · 全部移除 {count} 項"
},
"REMOVE ALL": "全部移除",
"Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已加入項目",
"Remove every added entry from Data Layers": "從數據圖層中移除所有已加入項目",
"Showing {shown} of {total}": "顯示 {shown} / {total}",
"BROWSE DATA LIBRARY": "瀏覽數據資源庫"
} }
+21 -6
View File
@@ -212,7 +212,10 @@
"ΔN", "ΔN",
"Ⅱ PAUSE", "Ⅱ PAUSE",
"≥ {value} MW", "≥ {value} MW",
"▲ {frp} MW" "▲ {frp} MW",
"COMMUNITY",
"Regional",
"OPT-IN"
], ],
"es": [ "es": [
"1090 MHz", "1090 MHz",
@@ -351,7 +354,9 @@
"Reptiles", "Reptiles",
"acartia.io ↗", "acartia.io ↗",
"iNaturalist ↗", "iNaturalist ↗",
"iNaturalist" "iNaturalist",
"Regional",
"{datasets} · {tiles} · {packs}"
], ],
"fr": [ "fr": [
"1090 MHz", "1090 MHz",
@@ -545,7 +550,12 @@
"ΔN", "ΔN",
"Ⅱ PAUSE", "Ⅱ PAUSE",
"≥ {value} MW", "≥ {value} MW",
"▲ {frp} MW" "▲ {frp} MW",
"PACK",
"{count} packs",
"Orthophoto",
"Oblique",
"Type"
], ],
"it": [ "it": [
"1090 MHz", "1090 MHz",
@@ -725,7 +735,10 @@
"CC BY 4.0", "CC BY 4.0",
"acartia.io ↗", "acartia.io ↗",
"iNaturalist ↗", "iNaturalist ↗",
"iNaturalist" "iNaturalist",
"DATASET",
"{count} datasets",
"ZOOM"
], ],
"ja": [ "ja": [
"1090 MHz", "1090 MHz",
@@ -919,7 +932,8 @@
"CC BY 4.0", "CC BY 4.0",
"acartia.io ↗", "acartia.io ↗",
"iNaturalist ↗", "iNaturalist ↗",
"iNaturalist" "iNaturalist",
"Regional"
], ],
"ru": [ "ru": [
"AIS: --", "AIS: --",
@@ -942,7 +956,8 @@
"WHOT/BHOT", "WHOT/BHOT",
"acartia.io ↗", "acartia.io ↗",
"iNaturalist", "iNaturalist",
"iNaturalist ↗" "iNaturalist ↗",
"{datasets} · {tiles} · {packs}"
], ],
"uk": [ "uk": [
"AIS: --", "AIS: --",
+3 -5
View File
@@ -132,11 +132,9 @@ export function createCards({ state: layerState, services, parts, source }) {
record.camera.lon, record.camera.lon,
), ),
inView, inView,
// Québec's browser-only sources have no registered frame endpoint; isVideo: parts.model.isVideoFeedType(
// keep them out of still-card fill just like video feeds. parts.model.normalizeFeedType(record.camera.feedType),
isVideo: ),
parts.model.isVideoFeedCamera(record.camera) ||
parts.model.isBrowserOverlayCamera(record.camera),
sx, sx,
sy, sy,
}); });
-5
View File
@@ -237,14 +237,9 @@ export function createCatalog({ state: layerState, services, parts, source }) {
groundElevationM, groundElevationM,
absoluteHeightM: groundElevationM + mountHeightM, absoluteHeightM: groundElevationM + mountHeightM,
pitchDeg, pitchDeg,
frameRefreshMs: parts.model.safeNumber(source.frameRefreshMs, NaN),
license: String(source.license || source.licenseNote || ''), license: String(source.license || source.licenseNote || ''),
credit: String(source.credit || ''), credit: String(source.credit || ''),
code: String(source.code || ''), code: String(source.code || ''),
sourceUrl: String(source.sourceUrl || ''),
browserMediaUrl: String(source.browserMediaUrl || ''),
accuracyM: parts.model.safeNumber(source.accuracyM, NaN),
positionSource: String(source.positionSource || ''),
// Shipped precompute (see server/providers/cctv/groundHeights.js). // Shipped precompute (see server/providers/cctv/groundHeights.js).
groundHeights: groundHeights:
source.groundHeights && typeof source.groundHeights === 'object' source.groundHeights && typeof source.groundHeights === 'object'
+2 -11
View File
@@ -255,20 +255,11 @@ export function createFrames({ state: layerState, services, parts, source }) {
// clears this latch so the next normal tick can refresh. // clears this latch so the next normal tick can refresh.
if (runtime.imageLoading) return; if (runtime.imageLoading) return;
const now = Date.now(); const now = Date.now();
const defaultRefreshMs = const refreshMs =
record.camera.id === layerState._activeCameraId record.camera.id === layerState._activeCameraId
? PROJECTION_ACTIVE_REFRESH_MS ? PROJECTION_ACTIVE_REFRESH_MS
: PROJECTION_IDLE_REFRESH_MS; : PROJECTION_IDLE_REFRESH_MS;
const sourceRefreshMs = parts.model.safeNumber( if (!force && now - runtime.lastImageRefreshAt < refreshMs) return;
record.camera.frameRefreshMs,
0,
);
const refreshMs = Math.max(defaultRefreshMs, sourceRefreshMs);
const sinceLastRefresh = now - runtime.lastImageRefreshAt;
// Forced activation may skip the normal active interval, but it cannot
// bypass a provider's explicit minimum between image requests.
if (sourceRefreshMs > 0 && sinceLastRefresh < sourceRefreshMs) return;
if (!force && sinceLastRefresh < refreshMs) return;
runtime.lastImageRefreshAt = now; runtime.lastImageRefreshAt = now;
const frameUrl = frameUrlFor(record.camera, refreshMs); const frameUrl = frameUrlFor(record.camera, refreshMs);
+3 -2
View File
@@ -49,8 +49,9 @@ export function createHover({ state: layerState, services, parts, source }) {
!!record && !!record &&
cameraId !== layerState._activeCameraId && cameraId !== layerState._activeCameraId &&
!layerState._cardIds.has(cameraId) && !layerState._cardIds.has(cameraId) &&
!parts.model.isBrowserOverlayCamera(record.camera) && !parts.model.isVideoFeedType(
!parts.model.isVideoFeedCamera(record.camera); parts.model.normalizeFeedType(record.camera.feedType),
);
if (eligible) { if (eligible) {
cancelHoverRelease(); cancelHoverRelease();
layerState._hoverCardId = cameraId; layerState._hoverCardId = cameraId;
-2
View File
@@ -112,8 +112,6 @@ export function createCctvLayer({ services, source }) {
_setCctvCoverageStateForTest: parts.testing._setCctvCoverageStateForTest, _setCctvCoverageStateForTest: parts.testing._setCctvCoverageStateForTest,
focusCctvRecord: parts.navigation.focusCctvRecord, focusCctvRecord: parts.navigation.focusCctvRecord,
maybeAutoHop: parts.navigation.maybeAutoHop, maybeAutoHop: parts.navigation.maybeAutoHop,
openingCameraId: parts.navigation.openingCameraId,
reopenNearestCamera: parts.navigation.reopenNearestCamera,
cctvCycleIndex: parts.navigation.cctvCycleIndex, cctvCycleIndex: parts.navigation.cctvCycleIndex,
}, },
); );
+6 -7
View File
@@ -151,7 +151,6 @@ export function createLifecycle({
layerState._enabled = false; layerState._enabled = false;
layerState._activeCameraId = null; layerState._activeCameraId = null;
layerState._autoHopSuspended = false; layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = false;
layerState._lastHopAt = 0; layerState._lastHopAt = 0;
layerState._lastViewContext = ''; layerState._lastViewContext = '';
layerState._calibrationById = parts.calibration.loadCalibrationStore(); layerState._calibrationById = parts.calibration.loadCalibrationStore();
@@ -300,8 +299,11 @@ export function createLifecycle({
} }
layerState._count = layerState._records.length; layerState._count = layerState._records.length;
// No camera is picked here: enable() opens on the one nearest the if (layerState._records.length > 0) {
// viewer, unless restored params selected one first. // Projection runtime + first frame fetch are deferred to enable() so
// initializing the catalog stays render-cheap.
layerState._activeCameraId = layerState._records[0].camera.id;
}
// Task 5: if the prior batch lost init's bounded race, apply it post-hoc // Task 5: if the prior batch lost init's bounded race, apply it post-hoc
// when it lands (pure recomputes — applyLateGroundPriors guards against // when it lands (pure recomputes — applyLateGroundPriors guards against
@@ -359,7 +361,6 @@ export function createLifecycle({
layerState._horizonCullListener = () => { layerState._horizonCullListener = () => {
layerState._cameraMoving = false; layerState._cameraMoving = false;
stopViewMotionTimer(); stopViewMotionTimer();
parts.navigation.reopenNearestCamera();
settleView(); settleView();
}; };
layerState._viewer.camera.moveEnd.addEventListener( layerState._viewer.camera.moveEnd.addEventListener(
@@ -507,9 +508,8 @@ export function createLifecycle({
return Boolean(coverage && layerState._recordById.has(coverage[1])); return Boolean(coverage && layerState._recordById.has(coverage[1]));
}); });
if (!layerState._activeCameraId && layerState._records.length) { if (!layerState._activeCameraId && layerState._records.length) {
layerState._activeCameraId = parts.navigation.openingCameraId(); layerState._activeCameraId = layerState._records[0].camera.id;
layerState._autoHopSuspended = false; layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = true;
} }
const activeRecord = parts.selection.getActiveRecord(); const activeRecord = parts.selection.getActiveRecord();
if (activeRecord) { if (activeRecord) {
@@ -629,7 +629,6 @@ export function createLifecycle({
layerState._enabled = false; layerState._enabled = false;
layerState._activeCameraId = null; layerState._activeCameraId = null;
layerState._autoHopSuspended = false; layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = false;
// Clear existing subscribers rather than replacing the Set — // Clear existing subscribers rather than replacing the Set —
// replacing would silently orphan any unsubscribe() closures // replacing would silently orphan any unsubscribe() closures
layerState._listeners.clear(); layerState._listeners.clear();
+1 -21
View File
@@ -96,19 +96,6 @@ export function createModel({ state: layerState, services, parts, source }) {
return feedType === 'mp4' || feedType === 'hls' || feedType === 'webm'; return feedType === 'mp4' || feedType === 'hls' || feedType === 'webm';
} }
/** A browser-only source still counts as live-capable for map icon/LOD policy. */
function isVideoFeedCamera(camera) {
return (
isVideoFeedType(normalizeFeedType(camera?.feedType)) ||
Boolean(camera?.browserMediaUrl)
);
}
/** Québec source pages and browser-played MP4s use the DOM overlay path. */
function isBrowserOverlayCamera(camera) {
return String(camera?.sourceKind || '').startsWith('quebec-');
}
/** /**
* Coerces a value to a finite number or returns the fallback. * Coerces a value to a finite number or returns the fallback.
* *
@@ -460,12 +447,7 @@ export function createModel({ state: layerState, services, parts, source }) {
function projectionLoopIsNeeded() { function projectionLoopIsNeeded() {
if (!layerState._enabled || !layerState._viewer) return false; if (!layerState._enabled || !layerState._viewer) return false;
const active = parts.selection.getActiveRecord(); if (layerState._showProjection && parts.selection.getActiveRecord())
if (
layerState._showProjection &&
active &&
!isBrowserOverlayCamera(active.camera)
)
return true; return true;
return focusPassIsNeeded( return focusPassIsNeeded(
getFocusTarget(), getFocusTarget(),
@@ -571,8 +553,6 @@ export function createModel({ state: layerState, services, parts, source }) {
angularDeltaAbs, angularDeltaAbs,
normalizeFeedType, normalizeFeedType,
isVideoFeedType, isVideoFeedType,
isVideoFeedCamera,
isBrowserOverlayCamera,
safeNumber, safeNumber,
headingFromId, headingFromId,
quantize, quantize,
-32
View File
@@ -33,36 +33,6 @@ export function createNavigation({
return best?.id || null; return best?.id || null;
} }
/**
* The camera the layer opens on when none is selected: the one nearest the
* viewer, or the first record when the viewer has no position yet.
* @returns {string|null} Camera ID, or null when the catalog is empty.
*/
function openingCameraId() {
return (
nearestCameraIdToViewer() || layerState._records[0]?.camera?.id || null
);
}
/**
* After a settled move, moves the opening camera to the one nearest the
* view, until someone chooses a camera. A shared view's flight lands after
* enable() picked from the startup view.
* @returns {boolean} True when the active camera changed.
*/
function reopenNearestCamera() {
if (!layerState._openingCameraProvisional || !layerState._enabled)
return false;
const nearest = nearestCameraIdToViewer();
if (!nearest || nearest === layerState._activeCameraId) return false;
parts.selection.setActiveCamera(nearest);
// setActiveCamera ends the opening pick; this is still that pick.
layerState._openingCameraProvisional = true;
return true;
}
/** /**
* Flies the Cesium viewer camera to frame the specified CCTV camera, * Flies the Cesium viewer camera to frame the specified CCTV camera,
* looking along its heading from above. * looking along its heading from above.
@@ -179,8 +149,6 @@ export function createNavigation({
} }
return { return {
nearestCameraIdToViewer, nearestCameraIdToViewer,
openingCameraId,
reopenNearestCamera,
focusCctvRecord, focusCctvRecord,
focusCamera, focusCamera,
maybeAutoHop, maybeAutoHop,
+6 -17
View File
@@ -160,14 +160,9 @@ export function createPresentation({
const camera = record.camera; const camera = record.camera;
const health = layerState._healthById.get(camera.id) || null; const health = layerState._healthById.get(camera.id) || null;
const isActive = camera.id === resolvedActiveId; const isActive = camera.id === resolvedActiveId;
const defaultRefreshMs = isActive const refreshMs = isActive
? ACTIVE_FRAME_REFRESH_MS ? ACTIVE_FRAME_REFRESH_MS
: IDLE_FRAME_REFRESH_MS; : IDLE_FRAME_REFRESH_MS;
const sourceRefreshMs = Number(camera.frameRefreshMs);
const refreshMs =
Number.isFinite(sourceRefreshMs) && sourceRefreshMs > 0
? Math.max(defaultRefreshMs, sourceRefreshMs)
: defaultRefreshMs;
return { return {
id: camera.id, id: camera.id,
name: camera.name, name: camera.name,
@@ -197,18 +192,13 @@ export function createPresentation({
mountHeightM: camera.mountHeightM, mountHeightM: camera.mountHeightM,
active: isActive, active: isActive,
feedType: camera.feedType, feedType: camera.feedType,
sourceUrl: camera.sourceUrl || '',
browserMediaUrl: camera.browserMediaUrl || '',
accuracyM: Number.isFinite(camera.accuracyM) ? camera.accuracyM : null,
positionSource: camera.positionSource || '',
isVideo: isVideo:
parts.model.isVideoFeedCamera(camera) && parts.model.isVideoFeedType(camera.feedType) &&
record.projection?.mode !== 'image', record.projection?.mode !== 'image',
sourceKind: camera.sourceKind?.startsWith('quebec-') sourceKind:
? camera.sourceKind health?.sourceKind ||
: health?.sourceKind || camera.sourceKind ||
camera.sourceKind || (camera.feedConfigured ? 'configured' : 'seed'),
(camera.feedConfigured ? 'configured' : 'seed'),
sourceStatus: health?.status || 'unknown', sourceStatus: health?.status || 'unknown',
sourceMessage: health?.message || '', sourceMessage: health?.message || '',
sourceLabel: health?.label || camera.provider || '', sourceLabel: health?.label || camera.provider || '',
@@ -286,7 +276,6 @@ export function createPresentation({
hoverId: layerState._hoverCardId, hoverId: layerState._hoverCardId,
}, },
activeCameraId: activeId, activeCameraId: activeId,
activeCameraReselection: layerState._activeCameraReselection,
activeCamera: active ? getPublicCameraState(active, activeId) : null, activeCamera: active ? getPublicCameraState(active, activeId) : null,
cameraIndex: cameraIndex(), cameraIndex: cameraIndex(),
summary: buildSummaryText(), summary: buildSummaryText(),
-3
View File
@@ -171,9 +171,6 @@ export function createProjection({
function createProjectionRuntime(record) { function createProjectionRuntime(record) {
if (!layerState._viewer) return null; if (!layerState._viewer) return null;
// Québec 511 denies CORS pixel access; Québec map previews are native DOM
// videos positioned over the globe, never Cesium video textures.
if (parts.model.isBrowserOverlayCamera(record.camera)) return null;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = PROJECTION_CANVAS_WIDTH; canvas.width = PROJECTION_CANVAS_WIDTH;
canvas.height = PROJECTION_CANVAS_HEIGHT; canvas.height = PROJECTION_CANVAS_HEIGHT;
Loaded 100 of 300 files, more files were not shown because too many files have changed in this diff. Show more